Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-empty-thought-dropdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": patch
---

Fixed the VS Code extension's thought dropdown expanding to nothing. Streamed tokens are batched behind a 150ms timer, and the reasoning/text routing flag was driven by the `reasoning-start` / `text-start` markers around a batch rather than by the deltas that filled it — so any provider whose ordering differs (the OpenAI Responses API defers `reasoning-end` until the reasoning item completes; openai-compatible providers reopen reasoning without closing text; some emit deltas with no start marker at all) delivered reasoning as assistant text and left the thought view empty. Routing now follows the delta type and flushes the pending batch before switching streams, so a batch always leaves on the callback it was filled for. Whitespace-only reasoning no longer emits an ACP thought chunk, is no longer stored on the message, and no longer opens a thought section, so the empty "Thought for 0s" bubbles are gone. Thanks to @akramcodez. Closes #853.
15 changes: 8 additions & 7 deletions plugins/vscode/media/chat-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -1746,13 +1746,14 @@
appendChunk(update.content.text);
}
} else if (update.sessionUpdate === 'agent_thought_chunk') {
if (!currentThoughtBox) {
endCurrentTextBlock();
currentThoughtBox = new ThoughtAggregator();
closeAggregatorIfIdle();
}
if (update.content && update.content.text) {
currentThoughtBox.append(update.content.text);
const thoughtText = update.content && update.content.text;
if (thoughtText && (currentThoughtBox || thoughtText.trim())) {
if (!currentThoughtBox) {
endCurrentTextBlock();
currentThoughtBox = new ThoughtAggregator();
closeAggregatorIfIdle();
}
currentThoughtBox.append(thoughtText);
}
} else if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
if (currentThoughtBox) {
Expand Down
30 changes: 30 additions & 0 deletions source/acp/acp-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,36 @@ test('AcpAgent.loadSession - replays in-memory history for a known session', asy
t.true(replayed.some(u => u.update.content.text === 'remember this'));
});

test('AcpAgent.loadSession - replays reasoning but skips whitespace-only reasoning', async t => {
const conn = createMockConn();
const updates: any[] = [];
conn.sessionUpdate = async (u: any) => {
updates.push(u);
};
const agent = new AcpAgent(createMockInitContext(), conn);
const session = await agent.newSession({cwd: '/tmp'});
const loaded = (agent as any).sessions.get(session.sessionId);
loaded.messages = [
{role: 'assistant', content: 'first', reasoning: '\n\n'},
{role: 'assistant', content: 'second', reasoning: 'weighing options'},
];

updates.length = 0;
await agent.loadSession({
sessionId: session.sessionId,
cwd: '/tmp',
mcpServers: [],
});

const thoughts = updates.filter(
u => u.update?.sessionUpdate === 'agent_thought_chunk',
);
t.deepEqual(
thoughts.map(u => u.update.content.text),
['weighing options'],
);
});

// ============================================================================
// setSessionConfigOption()
// ============================================================================
Expand Down
5 changes: 4 additions & 1 deletion source/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,10 @@ export class AcpAgent implements Agent {
});
}
} else if (message.role === 'assistant') {
if (message.reasoning && message.reasoning.length > 0) {
// runAcpConversation no longer stores whitespace-only reasoning, so
// this guard is for sessions written before that — replaying one
// would otherwise open a thought section that renders to nothing.
if (message.reasoning && message.reasoning.trim().length > 0) {
await this.conn.sessionUpdate({
sessionId: session.sessionId,
update: {
Expand Down
133 changes: 115 additions & 18 deletions source/acp/acp-conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,20 +256,121 @@ test('runAcpConversation - onToken sends agent_message_chunk updates', async t =
t.is(messageUpdates[0].update.content.text, 'Hello ');
});

// Streams the reasoning tokens from inside chat(), the way the real client
// does, so the assertions run against a live turn rather than a closure that
// happens to outlive it.
const createReasoningClient = (tokens: string[]): LLMClient =>
({
chat: async (_msgs: any, _tools: any, callbacks: any) => {
for (const token of tokens) {
callbacks.onReasoningToken(token);
}
return {choices: [{message: {content: 'done'}}]};
},
}) as unknown as LLMClient;

const thoughtTexts = (updates: any[]): string[] =>
updates
.filter((u: any) => u.update.sessionUpdate === 'agent_thought_chunk')
.map((u: any) => u.update.content.text);

test('runAcpConversation - onReasoningToken sends agent_thought_chunk updates', async t => {
const {conn, updates} = createMockConn();
const session = createMockSession(conn);
let capturedCallbacks: any = null;

await runAcpConversation({
session,
client: createReasoningClient(['thinking...']),
toolManager: createMockToolManager() as any,
conn,
nonInteractiveAlwaysAllow: [],
});

t.deepEqual(thoughtTexts(updates), ['thinking...']);
});

test('runAcpConversation - skips agent_thought_chunk for leading whitespace-only reasoning', async t => {
const {conn, updates} = createMockConn();
const session = createMockSession(conn);

await runAcpConversation({
session,
client: createReasoningClient([
'\n\n',
'Analyzing the request',
'\n\n',
'then answering',
]),
toolManager: createMockToolManager() as any,
conn,
nonInteractiveAlwaysAllow: [],
});

t.deepEqual(thoughtTexts(updates), [
'Analyzing the request',
'\n\n',
'then answering',
]);
});

test('runAcpConversation - stores exactly the reasoning it streamed', async t => {
const {conn, updates} = createMockConn();
const session = createMockSession(conn);

await runAcpConversation({
session,
client: createReasoningClient(['\n\n', ' Weighing', ' options']),
toolManager: createMockToolManager() as any,
conn,
nonInteractiveAlwaysAllow: [],
});

// replaySessionHistory re-sends the stored reasoning verbatim, so it has to
// be exactly what the live stream showed - no leading whitespace the thought
// chunks never carried.
const assistant = session.messages.find((m: any) => m.role === 'assistant');
t.is(assistant?.reasoning, thoughtTexts(updates).join(''));
t.is(assistant?.reasoning, 'Weighing options');
});

test('runAcpConversation - stores no reasoning when it was all whitespace', async t => {
const {conn, updates} = createMockConn();
const session = createMockSession(conn);

await runAcpConversation({
session,
client: createReasoningClient(['\n\n', ' ', '\t']),
toolManager: createMockToolManager() as any,
conn,
nonInteractiveAlwaysAllow: [],
});

t.deepEqual(thoughtTexts(updates), []);
const assistant = session.messages.find((m: any) => m.role === 'assistant');
t.is(assistant?.reasoning, undefined);
});

test('runAcpConversation - resets streamed reasoning between turns', async t => {
const {conn} = createMockConn();
const session = createMockSession(conn);
let turn = 0;
const client = {
chat: async (
_msgs: any,
_tools: any,
callbacks: any,
) => {
capturedCallbacks = callbacks;
return {
choices: [{message: {content: 'done'}}],
};
chat: async (_msgs: any, _tools: any, callbacks: any) => {
turn++;
callbacks.onReasoningToken(`turn ${turn} thought`);
if (turn === 1) {
return {
choices: [
{
message: {
content: '',
tool_calls: [createMockToolCall('read_file', {}, 'call-1')],
},
},
],
};
}
return {choices: [{message: {content: 'done'}}]};
},
} as unknown as LLMClient;

Expand All @@ -281,14 +382,10 @@ test('runAcpConversation - onReasoningToken sends agent_thought_chunk updates',
nonInteractiveAlwaysAllow: [],
});

t.truthy(capturedCallbacks);
capturedCallbacks.onReasoningToken('thinking...');

const thoughtUpdates = updates.filter(
(u: any) => u.update.sessionUpdate === 'agent_thought_chunk',
);
t.is(thoughtUpdates.length, 1);
t.is(thoughtUpdates[0].update.content.text, 'thinking...');
const reasonings = session.messages
.filter((m: any) => m.role === 'assistant')
.map((m: any) => m.reasoning);
t.deepEqual(reasonings, ['turn 1 thought', 'turn 2 thought']);
});

// ============================================================================
Expand Down
16 changes: 13 additions & 3 deletions source/acp/acp-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,22 @@ export async function runAcpConversation(

const callbacks: StreamCallbacks = {
onReasoningToken: (token: string) => {
streamedReasoning += token;
// Leading whitespace renders to nothing but still opens a thought
// section, leaving a bare "Thought for 0s" bubble, so drop it.
// Only what's emitted is accumulated: replaySessionHistory re-sends
// the stored reasoning verbatim, so anything skipped here has to
// stay out of the message or a reloaded session renders differently
// from the live one.
const text = streamedReasoning ? token : token.trimStart();
if (!text) {
return;
}
streamedReasoning += text;
conn.sessionUpdate({
sessionId: session.sessionId,
update: {
sessionUpdate: 'agent_thought_chunk',
content: {type: 'text', text: token},
content: {type: 'text', text},
},
});
},
Expand Down Expand Up @@ -237,7 +247,7 @@ export async function runAcpConversation(
role: 'assistant',
content: cleanedContent,
tool_calls: validToolCalls.length > 0 ? validToolCalls : undefined,
reasoning: streamedReasoning || undefined,
reasoning: streamedReasoning.trim() ? streamedReasoning : undefined,
},
];

Expand Down
Loading
Loading