fix: restore prompt id in server-v2 snapshots

This commit is contained in:
_Kerman 2026-07-04 17:14:50 +08:00
parent 8705d502f0
commit c0c9f155da
4 changed files with 171 additions and 5 deletions

View file

@ -1266,11 +1266,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// 1. Authoritative id captured at submit time.
let promptId = rawState.promptIdBySession[sid];
// 2. Fallback to projector-derived id only when it is a real daemon prompt_id
// (synthetic `pr_...` ids are rejected by the daemon).
// 2. Fallback to projector-derived id only when it is a real daemon prompt_id.
// The v1 daemon uses `prompt_...`, server-v2 legacy uses `msg_...`;
// only local synthetic `pr_...` ids are rejected by the daemon.
if (promptId === undefined) {
const candidate = session?.currentPromptId;
if (candidate?.startsWith('prompt_')) {
if (candidate !== undefined && candidate.length > 0 && !candidate.startsWith('pr_')) {
promptId = candidate;
}
}

View file

@ -236,6 +236,32 @@ describe('useWorkspaceState — abortCurrentPrompt', () => {
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('uses a server-v2 msg prompt id recovered from session state', async () => {
apiMock.abortPrompt.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'msg_live' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'msg_live');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('does not send synthetic projector prompt ids to per-prompt abort', async () => {
apiMock.abortSession.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'pr_synthetic' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).not.toHaveBeenCalled();
expect(apiMock.abortSession).toHaveBeenCalledWith('sess_1');
});
});
describe('mergeWorkspaces', () => {

View file

@ -16,15 +16,22 @@
import {
IAgentContextMemoryService,
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionInteractionService,
ISessionContext,
ISessionLifecycleService,
ISessionMetadata,
IWorkspaceRegistry,
toProtocolMessage,
type IAgentScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { ErrorCode, sessionSnapshotResponseSchema, type Message } from '@moonshot-ai/protocol';
import {
ErrorCode,
sessionSnapshotResponseSchema,
type InFlightTurn,
type Message,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
@ -111,6 +118,14 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
const offset = history.length - page.length;
items = page.map((msg, i) => toProtocolMessage(session_id, offset + i, msg, meta.createdAt));
}
const currentPromptId =
snapState.inFlightTurn === null
? undefined
: readCurrentPromptId(main);
const inFlightTurn = attachCurrentPromptIdToInFlight(
snapState.inFlightTurn,
currentPromptId,
);
// Pending approvals / questions.
const interaction = handle.accessor.get(ISessionInteractionService);
@ -128,7 +143,7 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
epoch: snapState.epoch,
session,
messages: { items, has_more: hasMore },
in_flight_turn: snapState.inFlightTurn,
in_flight_turn: inFlightTurn,
pending_approvals: pendingApprovals,
pending_questions: pendingQuestions,
},
@ -139,3 +154,21 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
);
app.get(route.path, route.options, route.handler as Parameters<SnapshotRouteHost['get']>[2]);
}
function readCurrentPromptId(main: IAgentScopeHandle | undefined): string | undefined {
if (main === undefined) return undefined;
try {
return main.accessor.get(IAgentPromptLegacyService).list().active?.prompt_id;
} catch {
// Auxiliary reconnect metadata must not make the whole snapshot fail.
return undefined;
}
}
function attachCurrentPromptIdToInFlight(
inFlightTurn: InFlightTurn | null,
currentPromptId: string | undefined,
): InFlightTurn | null {
if (inFlightTurn === null || currentPromptId === undefined) return inFlightTurn;
return { ...inFlightTurn, current_prompt_id: currentPromptId };
}

View file

@ -11,15 +11,121 @@ import {
IAgentContextMemoryService,
IAgentEventSinkService,
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionInteractionService,
ISessionContext,
ISessionLifecycleService,
ISessionMetadata,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
import { sessionSnapshotResponseSchema, type AgentEvent } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerSnapshotRoutes } from '../src/routes/snapshot';
import { type RunningServer, startServer } from '../src/start';
import { authHeaders } from './helpers/auth';
function fakeAccessor(entries: ReadonlyArray<readonly [unknown, unknown]>) {
const services = new Map<unknown, unknown>(entries);
return {
get<T>(id: unknown): T {
if (!services.has(id)) {
throw new Error(`unexpected service request: ${String(id)}`);
}
return services.get(id) as T;
},
};
}
describe('server-v2 snapshot route enrichment', () => {
it('attaches current_prompt_id to an in-flight turn from promptLegacy active state', async () => {
const sessionId = 'sess_snapshot';
const promptId = 'msg_snapshot_prompt';
const workspaceId = 'wd_snapshot_012345abcdef';
const now = Date.parse('2026-01-01T00:00:00.000Z');
const main = {
accessor: fakeAccessor([
[IAgentContextMemoryService, { get: () => [] }],
[
IAgentPromptLegacyService,
{ list: () => ({ active: { prompt_id: promptId }, queued: [] }) },
],
]),
};
const session = {
accessor: fakeAccessor([
[ISessionContext, { workspaceId }],
[
ISessionMetadata,
{
read: async () => ({
id: sessionId,
title: 'Snapshot',
createdAt: now,
updatedAt: now,
archived: false,
}),
},
],
[IAgentLifecycleService, { getHandle: () => main }],
[ISessionInteractionService, { listPending: () => [] }],
]),
};
const core = {
accessor: fakeAccessor([
[ISessionLifecycleService, { resume: async () => session }],
[IWorkspaceRegistry, { get: async () => ({ root: '/workspace' }) }],
]),
};
const broadcaster = {
getSnapshotState: async () => ({
seq: 1,
epoch: 'ep_snapshot',
inFlightTurn: {
turn_id: 7,
assistant_text: 'Hello',
thinking_text: '',
running_tools: [],
},
}),
};
let routeHandler:
| ((
req: { id: string; params: { session_id: string } },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void)
| undefined;
registerSnapshotRoutes(
{
get: (_path, _options, handler) => {
routeHandler = handler;
},
},
{ core: core as never, broadcaster: broadcaster as never },
);
let payload: unknown;
await routeHandler?.(
{ id: 'req_snapshot', params: { session_id: sessionId } },
{
send: (value) => {
payload = value;
},
},
);
const body = payload as { code: number; data: unknown };
expect(body.code).toBe(0);
const snap = sessionSnapshotResponseSchema.parse(body.data);
expect(snap.in_flight_turn).toMatchObject({
turn_id: 7,
assistant_text: 'Hello',
current_prompt_id: promptId,
});
});
});
describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
let server: RunningServer | undefined;
let home: string | undefined;