Skip to content

Commit 08da51b

Browse files
test: expand daemon and UI e2e coverage (#5107)
* test: expand daemon and UI e2e coverage * fix: refresh retry assistant metadata Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * fix: persist inherited run session mode Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * fix: stabilize AMR inline auth retry Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * fix: reconcile legacy chat assistant pins Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * test: stabilize fake AMR auth recovery Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * test: restore e2e interaction actionability Generated-By: looper 0.9.11 (runner=fixer, agent=codex) * test: address review feedback for PR 5107 * test: stabilize PR 5107 CI interactions * test: tighten PR 5107 review coverage * fix: inherit run session mode after conversation fallback * test: assert AMR promotion retry renders recovery Generated-By: looper 0.10.1 (runner=fixer, agent=codex) --------- Co-authored-by: Amy <1184569493@qq.com>
1 parent 7d005a1 commit 08da51b

19 files changed

Lines changed: 3016 additions & 192 deletions

apps/daemon/src/routes/project/conversations.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { backfillBrandExtractionTranscriptForProject } from '../../brands/index.
55
import type { RouteDeps } from '../../server-context.js';
66
import { registerProjectCommentRoutes } from './comments.js';
77

8-
export interface RegisterProjectConversationRoutesDeps extends RouteDeps<'db' | 'paths' | 'projectStore' | 'conversations' | 'ids' | 'telemetry' | 'appConfig' | 'agents'> {}
8+
export interface RegisterProjectConversationRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'conversations' | 'ids' | 'telemetry' | 'appConfig' | 'agents'> {}
99

1010
function normalizeChatSessionMode(value: unknown): ChatSessionMode {
1111
return value === 'chat' || value === 'plan' ? value : 'design';
1212
}
1313

14+
function isChatSessionMode(value: unknown): value is ChatSessionMode {
15+
return value === 'chat' || value === 'design' || value === 'plan';
16+
}
17+
1418
export function registerProjectConversationRoutes(app: Express, ctx: RegisterProjectConversationRoutesDeps): void {
1519
const { db } = ctx;
20+
const { sendApiError } = ctx.http;
1621
const { getProject, updateProject } = ctx.projectStore;
1722
const {
1823
insertConversation,
@@ -46,6 +51,9 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
4651
const hasExplicitSessionMode = Boolean(
4752
req.body && Object.prototype.hasOwnProperty.call(req.body, 'sessionMode'),
4853
);
54+
if (hasExplicitSessionMode && !isChatSessionMode(req.body.sessionMode)) {
55+
return sendApiError(res, 400, 'BAD_REQUEST', 'sessionMode must be one of design, chat, or plan');
56+
}
4957
const requestedForkMessageId =
5058
typeof forkAfterMessageId === 'string' && forkAfterMessageId
5159
? forkAfterMessageId
@@ -90,7 +98,7 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
9098
}
9199
const sessionMode =
92100
hasExplicitSessionMode
93-
? normalizeChatSessionMode(req.body.sessionMode)
101+
? req.body.sessionMode
94102
: sourceConversation && sourceConversation.projectId === req.params.id
95103
? normalizeChatSessionMode(sourceConversation.sessionMode)
96104
: 'design';
@@ -129,6 +137,13 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
129137
if (!conv || conv.projectId !== req.params.id) {
130138
return res.status(404).json({ error: 'not found' });
131139
}
140+
if (
141+
req.body &&
142+
Object.prototype.hasOwnProperty.call(req.body, 'sessionMode') &&
143+
!isChatSessionMode(req.body.sessionMode)
144+
) {
145+
return sendApiError(res, 400, 'BAD_REQUEST', 'sessionMode must be one of design, chat, or plan');
146+
}
132147
const updated = updateConversation(db, req.params.cid, req.body || {});
133148
res.json({ conversation: updated });
134149
});

apps/daemon/src/routes/runs.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ import {
3131
readCodexRolloutFirstCall,
3232
} from '../codex-rollout-usage.js';
3333
import type { ConnectorService } from '../connectors/service.js';
34-
import { getProject, listConversations, updateProject, upsertMessage } from '../db.js';
34+
import {
35+
getConversation,
36+
getProject,
37+
listConversations,
38+
normalizeConversationSessionMode,
39+
updateProject,
40+
upsertMessage,
41+
} from '../db.js';
3542
import { readVelaLoginStatus } from '../integrations/vela.js';
3643
import {
3744
deriveLangfuseDeliveryState,
@@ -140,6 +147,8 @@ interface ChatRun {
140147
appliedPluginSnapshotId?: string | null;
141148
pluginId?: string | null;
142149
clientType?: 'desktop' | 'web';
150+
sessionMode?: string | null;
151+
context?: Record<string, unknown> | null;
143152
events: RunEventRecord[];
144153
clients: Set<SseClient>;
145154
analyticsContext?: AnalyticsContext;
@@ -635,6 +644,14 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
635644
console.warn('[runs] mcp conversation fallback failed', err);
636645
}
637646
}
647+
const conversationSession =
648+
typeof meta.conversationId === 'string' && meta.conversationId
649+
? getConversation(db, meta.conversationId)
650+
: null;
651+
meta.sessionMode =
652+
meta.sessionMode === 'chat' || meta.sessionMode === 'design' || meta.sessionMode === 'plan'
653+
? normalizeConversationSessionMode(meta.sessionMode)
654+
: normalizeConversationSessionMode(conversationSession?.sessionMode);
638655
const run = design.runs.create(meta);
639656
try {
640657
pinAssistantMessageOnRunCreate(db, run);
@@ -1433,6 +1450,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
14331450
};
14341451
const run = design.runs.create(meta);
14351452
design.runs.stream(run, req, res);
1453+
reconcileAssistantMessageOnRunEnd(db, design.runs, run);
14361454
design.runs.start(run, () => startChatRun(meta, run));
14371455
});
14381456
}

apps/daemon/src/runtimes/chat-run-messages.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type ChatRunMessageState = {
1414
agentId?: string | null;
1515
status?: string;
1616
createdAt?: number;
17+
sessionMode?: string | null;
18+
context?: Record<string, unknown> | null;
1719
};
1820

1921
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -214,9 +216,18 @@ export function pinAssistantMessageOnRunCreate(db: SqliteDb, run: ChatRunMessage
214216
WHEN run_status IN ('succeeded', 'failed', 'canceled') THEN run_status
215217
ELSE ?
216218
END,
219+
session_mode = ?,
220+
run_context_json = ?,
217221
started_at = COALESCE(started_at, ?)
218222
WHERE id = ?`,
219-
).run(run.id, run.status, run.createdAt, run.assistantMessageId);
223+
).run(
224+
run.id,
225+
run.status,
226+
run.sessionMode ?? null,
227+
run.context ? JSON.stringify(run.context) : null,
228+
run.createdAt,
229+
run.assistantMessageId,
230+
);
220231
return;
221232
}
222233
upsertMessage(db, run.conversationId, {
@@ -227,6 +238,8 @@ export function pinAssistantMessageOnRunCreate(db: SqliteDb, run: ChatRunMessage
227238
events: [],
228239
runId: run.id,
229240
runStatus: run.status,
241+
sessionMode: run.sessionMode ?? undefined,
242+
runContext: run.context ?? undefined,
230243
startedAt: run.createdAt,
231244
});
232245
}

apps/daemon/src/runtimes/runs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export function createChatRunService({
7070
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
7171
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
7272
browserUse: meta.browserUse && typeof meta.browserUse === 'object' ? meta.browserUse : null,
73+
sessionMode:
74+
meta.sessionMode === 'chat' || meta.sessionMode === 'design' || meta.sessionMode === 'plan'
75+
? meta.sessionMode
76+
: null,
77+
context:
78+
meta.context && typeof meta.context === 'object' && !Array.isArray(meta.context)
79+
? meta.context
80+
: null,
7381
status: 'queued',
7482
createdAt: now,
7583
updatedAt: now,

apps/daemon/tests/chat-route.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,62 @@ process.stdin.on('end', () => {
736736
}
737737
});
738738

739+
it('does not leave a pinned assistant message queued when legacy chat fails before spawning', async () => {
740+
if (!process.env.OD_DATA_DIR) {
741+
throw new Error('OD_DATA_DIR is required for assistant message pin tests');
742+
}
743+
const projectId = `proj-${randomUUID()}`;
744+
const assistantMessageId = `assistant-failed-${randomUUID()}`;
745+
746+
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
747+
method: 'POST',
748+
headers: { 'Content-Type': 'application/json' },
749+
body: JSON.stringify({ id: projectId, name: 'Failed assistant pin fixture' }),
750+
});
751+
expect(createProjectResponse.ok).toBe(true);
752+
753+
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
754+
expect(conversationsResponse.ok).toBe(true);
755+
const conversationsBody = await conversationsResponse.json() as {
756+
conversations: Array<{ id: string }>;
757+
};
758+
const conversationId = conversationsBody.conversations[0]?.id;
759+
expect(conversationId).toBeTruthy();
760+
761+
const response = await fetch(`${baseUrl}/api/chat`, {
762+
method: 'POST',
763+
headers: { 'Content-Type': 'application/json' },
764+
body: JSON.stringify({
765+
agentId: `missing-agent-${randomUUID()}`,
766+
projectId,
767+
conversationId,
768+
assistantMessageId,
769+
message: 'fail before spawn',
770+
}),
771+
});
772+
const body = await response.text();
773+
expect(response.ok).toBe(true);
774+
expect(body).toContain('unknown agent');
775+
776+
const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
777+
let lastStatus: string | null = null;
778+
for (let attempt = 0; attempt < 100; attempt += 1) {
779+
const sqlite = new Database(dbFile, { readonly: true });
780+
try {
781+
const row = sqlite
782+
.prepare(`SELECT run_status FROM messages WHERE id = ?`)
783+
.get(assistantMessageId) as { run_status: string | null } | undefined;
784+
lastStatus = row?.run_status ?? null;
785+
if (lastStatus && lastStatus !== 'queued' && lastStatus !== 'running') break;
786+
} finally {
787+
sqlite.close();
788+
}
789+
await new Promise((resolve) => setTimeout(resolve, 25));
790+
}
791+
792+
expect(lastStatus).toBe('failed');
793+
});
794+
739795
it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
740796
const conversationId = `conv-${randomUUID()}`;
741797

apps/daemon/tests/mcp-get-artifact.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net';
33
import express from 'express';
44
import type { Express } from 'express';
55
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6-
import { getArtifact, fetchProjectFile } from '../src/mcp.js';
6+
import { getArtifact, fetchProjectFile, handleMcpToolCall } from '../src/mcp.js';
77

88
// A minimal mock of the daemon's project file endpoints. Tests control
99
// the file list and per-file response via the opts object.
@@ -178,3 +178,54 @@ describe('getArtifact truncated: true when per-file content-length pre-check fir
178178
expect(body.files.length).toBe(1);
179179
});
180180
});
181+
182+
describe('public MCP get_artifact active context defaults', () => {
183+
let server: http.Server;
184+
let baseUrl: string;
185+
186+
beforeAll(async () => {
187+
const app = express();
188+
app.get('/api/active', (_req, res) =>
189+
res.json({
190+
active: true,
191+
projectId: 'active-project',
192+
projectName: 'Active Project',
193+
fileName: 'landing.html',
194+
ageMs: 50,
195+
}),
196+
);
197+
app.get('/api/projects/:id', (_req, res) =>
198+
res.json({
199+
project: {
200+
id: 'active-project',
201+
name: 'Active Project',
202+
metadata: { entryFile: 'index.html' },
203+
},
204+
}),
205+
);
206+
app.get('/api/projects/:id/raw/*splat', (req, res) => {
207+
expect(req.params.id).toBe('active-project');
208+
expect(req.params.splat).toEqual(['landing.html']);
209+
res.set({ 'content-type': 'text/html' }).send('<!doctype html><h1>Active artifact</h1>');
210+
});
211+
const r = await startServer(app);
212+
server = r.server;
213+
baseUrl = r.baseUrl;
214+
});
215+
216+
afterAll(() => new Promise((resolve) => server.close(resolve)));
217+
218+
it('uses the active file ahead of metadata.entryFile when project and entry are omitted', async () => {
219+
const result = await handleMcpToolCall(baseUrl, 'get_artifact', { include: 'shallow' });
220+
const body = parseArtifactBody(firstText(result.content)) as ArtifactBody & {
221+
entryFile?: string;
222+
usedActiveContext?: { projectId?: string; fileName?: string };
223+
};
224+
expect(body.entryFile).toBe('landing.html');
225+
expect(body.files).toHaveLength(1);
226+
expect(body.usedActiveContext).toMatchObject({
227+
projectId: 'active-project',
228+
fileName: 'landing.html',
229+
});
230+
});
231+
});

apps/daemon/tests/mcp-get-file.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net';
33
import express from 'express';
44
import type { Express } from 'express';
55
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6-
import { getFile } from '../src/mcp.js';
6+
import { getFile, handleMcpToolCall } from '../src/mcp.js';
77

88
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
99

@@ -131,3 +131,38 @@ describe('getFile binary rejection unchanged', () => {
131131
expect(text).toMatch(/binary content is not yet supported/);
132132
});
133133
});
134+
135+
describe('public MCP get_file active context defaults', () => {
136+
let server: http.Server;
137+
let baseUrl: string;
138+
139+
beforeAll(async () => {
140+
const app = express();
141+
app.get('/api/active', (_req, res) =>
142+
res.json({
143+
active: true,
144+
projectId: 'active-project',
145+
projectName: 'Active Project',
146+
fileName: 'landing.html',
147+
ageMs: 25,
148+
}),
149+
);
150+
app.get('/api/projects/:id/raw/*splat', (req, res) => {
151+
expect(req.params.id).toBe('active-project');
152+
expect(req.params.splat).toEqual(['landing.html']);
153+
res.set({ 'content-type': 'text/html' }).send('<!doctype html><h1>Active file</h1>');
154+
});
155+
const r = await startServer(app);
156+
server = r.server;
157+
baseUrl = r.baseUrl;
158+
});
159+
160+
afterAll(() => new Promise((resolve) => server.close(resolve)));
161+
162+
it('uses the active project and active file when project and path are omitted', async () => {
163+
const result = await handleMcpToolCall(baseUrl, 'get_file', {});
164+
const textParts = contentTexts(result.content);
165+
expect(textParts[0]).toContain('[od:active-context project="Active Project" file="landing.html"]');
166+
expect(lastText(textParts)).toContain('<h1>Active file</h1>');
167+
});
168+
});

0 commit comments

Comments
 (0)