fix(vscode-ide-companion): echo user prompt into WebShell transcript

This commit is contained in:
jinjing.zzj 2026-08-23 12:19:31 +08:00
parent c2cffbb7b7
commit bd09e19d86
2 changed files with 137 additions and 0 deletions

View file

@ -225,6 +225,64 @@ describe('SessionMessageHandler', () => {
]);
});
it('echoes the user prompt into the ACP transcript as a user_message_chunk', async () => {
mockProcessImageAttachments.mockResolvedValue({
formattedText: 'hello transcript',
displayText: 'hello transcript',
savedImageCount: 0,
promptImages: [],
});
const agentManager = {
isConnected: true,
currentSessionId: 'session-1',
sendMessage: vi.fn().mockResolvedValue(undefined),
};
const conversationStore = {
createConversation: vi.fn().mockResolvedValue({ id: 'conversation-1' }),
getConversation: vi.fn().mockResolvedValue(null),
addMessage: vi.fn(),
renameConversationId: vi.fn().mockResolvedValue(true),
};
const sendToWebView = vi.fn();
const handler = new SessionMessageHandler(
agentManager as never,
conversationStore as never,
null,
sendToWebView,
);
await handler.handle({
type: 'sendMessage',
data: { text: 'hello transcript' },
});
// The direct stdio ACP channel never emits user_message_chunk for an
// interactive prompt; the handler must synthesize it so the user's own
// turn renders in the WebShell transcript timeline.
expect(sendToWebView).toHaveBeenCalledWith({
type: 'transcriptUpdate',
data: {
sessionId: 'session-1',
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'hello transcript' },
},
},
});
// The echo must be posted before the prompt is dispatched so the user
// block renders ahead of this turn's assistant frames.
const echoCallIndex = sendToWebView.mock.calls.findIndex(
(call) =>
(call[0] as { type?: string } | undefined)?.type === 'transcriptUpdate',
);
expect(echoCallIndex).toBeGreaterThanOrEqual(0);
expect(sendToWebView.mock.invocationCallOrder[echoCallIndex]).toBeLessThan(
agentManager.sendMessage.mock.invocationCallOrder[0],
);
});
it('sends image file context as prompt image blocks', async () => {
mockProcessImageAttachments.mockImplementation(
async (promptText: string) => ({
@ -990,6 +1048,66 @@ describe('SessionMessageHandler', () => {
);
});
it('publishes the fresh ACP session id as liveSessionId in the load-failure fallback boundary', async () => {
const archivedSessionId = 'archived-session';
const agentManager: {
isConnected: boolean;
currentSessionId: string | null;
getSessionList: ReturnType<typeof vi.fn>;
loadSessionViaAcp: ReturnType<typeof vi.fn>;
getSessionMessages: ReturnType<typeof vi.fn>;
createNewSession: ReturnType<typeof vi.fn>;
} = {
isConnected: true,
currentSessionId: 'old-acp-session',
getSessionList: vi
.fn()
.mockResolvedValue([{ id: archivedSessionId, cwd: '/workspace' }]),
loadSessionViaAcp: vi
.fn()
.mockRejectedValue(new Error('session not found on server')),
getSessionMessages: vi.fn().mockResolvedValue([]),
createNewSession: vi.fn(),
};
// Mirror the real manager: session/new flips currentSessionId to the
// freshly created ACP session.
agentManager.createNewSession.mockImplementation(async () => {
agentManager.currentSessionId = 'new-acp-session';
return 'new-acp-session';
});
const conversationStore = {
createConversation: vi.fn(),
getConversation: vi.fn(),
addMessage: vi.fn(),
};
const sendToWebView = vi.fn();
const handler = new SessionMessageHandler(
agentManager as never,
conversationStore as never,
null,
sendToWebView,
);
await handler.handle({
type: 'switchQwenSession',
data: { sessionId: archivedSessionId },
});
// The transcript filter must learn the live session id from the
// boundary; otherwise every live frame of the fresh session is
// dropped because it does not carry the archived id.
expect(sendToWebView).toHaveBeenCalledWith(
expect.objectContaining({
type: 'qwenSessionSwitched',
data: expect.objectContaining({
sessionId: archivedSessionId,
liveSessionId: 'new-acp-session',
}),
}),
);
});
it('forces a fresh ACP session when the webview requests a new session', async () => {
const agentManager = {
isConnected: true,

View file

@ -871,6 +871,25 @@ export class SessionMessageHandler extends BaseMessageHandler {
},
});
// The companion's direct stdio ACP channel never receives a
// user_message_chunk for an interactive prompt, so echo the user's
// own turn into the transcript timeline (mirrors the daemon-bridge
// echo on the SSE bus). Posted before the prompt is dispatched so
// the user block renders ahead of this turn's assistant frames.
const transcriptEchoSessionId = this.agentManager.currentSessionId;
if (transcriptEchoSessionId && displayText) {
this.sendToWebView({
type: 'transcriptUpdate',
data: {
sessionId: transcriptEchoSessionId,
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: displayText },
},
},
});
}
await this.agentManager.sendMessage(
buildPromptBlocks(promptText, promptImages),
);