fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
218 lines
8.0 KiB
TypeScript
218 lines
8.0 KiB
TypeScript
/**
|
|
* Tier-B · agent preview persists a HIDDEN conversation
|
|
*
|
|
* Covers B14. The preview flow in `frontend/src/agents/agentPreviewSlice.ts`
|
|
* calls `handleFetchAnswerSteaming(..., save_conversation=false)`, which the
|
|
* handler maps to `visibility: 'hidden'`. Conversations always persist;
|
|
* visibility defaults to `hidden` for every request and only an explicit
|
|
* `visibility: 'listed'` (sent by the UI on normal chats) lists a row in the
|
|
* sidebar (decided by `resolve_persistence` in
|
|
* application/api/answer/services/persistence_policy.py and threaded through
|
|
* `BaseAnswerResource.complete_stream(visibility=...)`).
|
|
*
|
|
* The load-bearing assertion is: after a preview stream completes, the row
|
|
* exists but is `hidden`, so it is excluded from the sidebar query
|
|
* (`ConversationsRepository.list_for_user`, which filters `visibility =
|
|
* 'listed'`). If a regression flips the preview default to `listed`, preview
|
|
* traffic would pollute the user's sidebar history.
|
|
*
|
|
* API-driven: the frontend preview UI ultimately POSTs /stream with
|
|
* `visibility:'hidden'` and `conversation_id:null`; we exercise that
|
|
* contract directly to keep the spec fast and isolated from the AgentPreview
|
|
* modal's rendering quirks. A standard chat POST (visibility:'listed', as the
|
|
* UI sends) is the control — it writes a `listed` row that DOES surface in
|
|
* the sidebar.
|
|
*/
|
|
|
|
import * as playwright from '@playwright/test';
|
|
const { expect, test } = playwright;
|
|
|
|
import { authedRequest } from '../../helpers/api.js';
|
|
import { newUserContext } from '../../helpers/auth.js';
|
|
import { pg } from '../../helpers/db.js';
|
|
import { resetDb } from '../../helpers/reset.js';
|
|
import { streamOnce } from '../../helpers/streaming.js';
|
|
|
|
/**
|
|
* Insert a minimal draft agent owned by `userSub`. Preview works on
|
|
* drafts — the frontend's AgentPreview modal opens over in-progress
|
|
* drafts before publish.
|
|
*/
|
|
async function createDraftAgent(userSub: string, name: string): Promise<string> {
|
|
const { rows } = await pg.query<{ id: string }>(
|
|
`INSERT INTO agents (user_id, name, status, retriever, chunks, agent_type)
|
|
VALUES ($1, $2, 'draft', 'classic', 2, 'classic')
|
|
RETURNING id::text AS id`,
|
|
[userSub, name],
|
|
);
|
|
const id = rows[0]?.id;
|
|
if (!id) throw new Error(`createDraftAgent failed for ${name}`);
|
|
return id;
|
|
}
|
|
|
|
async function countUserConversations(userSub: string): Promise<number> {
|
|
const { rows } = await pg.query<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM conversations WHERE user_id = $1`,
|
|
[userSub],
|
|
);
|
|
return Number(rows[0]?.n ?? 0);
|
|
}
|
|
|
|
/** Conversations that surface in the sidebar (the `list_for_user` filter). */
|
|
async function countListedConversations(userSub: string): Promise<number> {
|
|
const { rows } = await pg.query<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM conversations
|
|
WHERE user_id = $1 AND visibility = 'listed'`,
|
|
[userSub],
|
|
);
|
|
return Number(rows[0]?.n ?? 0);
|
|
}
|
|
|
|
async function countConversationMessages(userSub: string): Promise<number> {
|
|
const { rows } = await pg.query<{ n: string }>(
|
|
`SELECT count(*)::text AS n
|
|
FROM conversation_messages cm
|
|
JOIN conversations c ON c.id = cm.conversation_id
|
|
WHERE c.user_id = $1`,
|
|
[userSub],
|
|
);
|
|
return Number(rows[0]?.n ?? 0);
|
|
}
|
|
|
|
/**
|
|
* POST /stream with save_conversation=false and drain the SSE body. The
|
|
* stream emits `{type:"id", id:"<uuid>"}` once the (hidden) conversation is
|
|
* persisted. Sidebar membership is asserted via the DB helpers below rather
|
|
* than the stream id, so we just drain the response and assert 200.
|
|
*/
|
|
async function drainStream(
|
|
api: import('@playwright/test').APIRequestContext,
|
|
body: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const res = await api.post('/stream', { data: body });
|
|
expect(
|
|
res.status(),
|
|
`/stream expected 200, got ${res.status()} ${await res.text()}`,
|
|
).toBe(200);
|
|
// Drain so the server finishes its work (including any persistence
|
|
// branches). The SSE body ends with `data: {"type": "end"}`.
|
|
const text = await res.text();
|
|
expect(text).toContain('"type": "end"');
|
|
}
|
|
|
|
test.describe('tier-b · agent preview', () => {
|
|
test.beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
test('preview stream with save_conversation=false persists a hidden conversation excluded from the sidebar', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const agentId = await createDraftAgent(sub, 'preview-target');
|
|
|
|
// Pre-state: zero conversations for this user.
|
|
expect(await countUserConversations(sub)).toBe(0);
|
|
|
|
// Fire the preview payload that agentPreviewSlice.ts sends. Key
|
|
// fields: visibility='hidden', conversation_id=null, agent_id.
|
|
await drainStream(api, {
|
|
question: 'Hello preview',
|
|
conversation_id: null,
|
|
agent_id: agentId,
|
|
save_conversation: false,
|
|
visibility: 'hidden',
|
|
isNoneDoc: true,
|
|
chunks: '0',
|
|
});
|
|
|
|
// The turn persists (a row + messages exist)...
|
|
expect(
|
|
await countUserConversations(sub),
|
|
'preview stream persists a conversation row',
|
|
).toBe(1);
|
|
expect(
|
|
await countConversationMessages(sub),
|
|
'preview stream persists conversation_messages rows',
|
|
).toBeGreaterThan(0);
|
|
// ...but it is hidden, so the sidebar query returns nothing.
|
|
expect(
|
|
await countListedConversations(sub),
|
|
'preview conversation must not surface in the sidebar',
|
|
).toBe(0);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('control: a non-preview stream (visibility: listed, as the UI sends) writes a LISTED conversations row', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const agentId = await createDraftAgent(sub, 'non-preview-target');
|
|
expect(await countUserConversations(sub)).toBe(0);
|
|
|
|
// Standard chat path — the UI sends `visibility: 'listed'` on normal
|
|
// chats (visibility defaults to hidden server-side). This is the
|
|
// "control" that makes the preview assertion meaningful: without
|
|
// it, a spec where nothing ever surfaces could pass trivially.
|
|
const conversationId = await streamOnce(api, {
|
|
question: 'Hello persistence',
|
|
conversation_id: null,
|
|
agent_id: agentId,
|
|
visibility: 'listed',
|
|
isNoneDoc: true,
|
|
chunks: '0',
|
|
});
|
|
expect(conversationId).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
);
|
|
|
|
// Exactly one conversation row landed, and it surfaces in the sidebar.
|
|
expect(await countUserConversations(sub)).toBe(1);
|
|
expect(await countListedConversations(sub)).toBe(1);
|
|
expect(await countConversationMessages(sub)).toBeGreaterThan(0);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('multiple preview turns persist hidden rows that never surface in the sidebar', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const agentId = await createDraftAgent(sub, 'preview-repeat');
|
|
expect(await countUserConversations(sub)).toBe(0);
|
|
|
|
for (const question of ['first', 'second', 'third']) {
|
|
await drainStream(api, {
|
|
question,
|
|
conversation_id: null,
|
|
agent_id: agentId,
|
|
save_conversation: false,
|
|
visibility: 'hidden',
|
|
isNoneDoc: true,
|
|
chunks: '0',
|
|
});
|
|
}
|
|
|
|
// Each turn persisted its own hidden conversation...
|
|
expect(await countUserConversations(sub)).toBe(3);
|
|
// ...yet the sidebar stays empty no matter how many "Test message"
|
|
// clicks the user makes.
|
|
expect(await countListedConversations(sub)).toBe(0);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|