Skip to content

Commit 0f823e2

Browse files
giawaflorintimbuc
andauthored
fix: sync agent state on standalone webview connect (#371)
* fix: sync agent state on standalone webview connect When a webview connects via WebSocket (standalone mode), the server now sends active tool statuses, waiting status, and team metadata in addition to context usage. This matches the VS Code adapter behavior and prevents agents from appearing idle until their next update. Fixes the issue where all agents show 'Idle' on initial connection even when they're actively working. * fix: extract agent activity replay to shared helper Extract sendCurrentAgentStatuses and handleWebviewReady replay logic into agentActivityResend.ts to prevent drift between VS Code and standalone adapters. Fix ghost Subtask characters appearing on reconnect by: - Sending team info BEFORE tool messages (webview needs context) - Including runInBackground and isTeammateSpawn flags - Skipping promoted background agents (hasPromotedBackgroundAgent) Add 7 focused tests covering message ordering, tool flags, and edge cases. All tests pass. * refactor: remove comment that doesn't really need to be there, too specific to the PR feedback * test: pin background replay flags, team ordering, and standalone connect wiring --------- Co-authored-by: Florin Timbuc <florin@sowild.design>
1 parent 9794e07 commit 0f823e2

5 files changed

Lines changed: 348 additions & 54 deletions

File tree

adapters/vscode/agentManager.ts

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path';
44
import * as vscode from 'vscode';
55

66
import type { StateAdapter } from '../../core/src/adapter.js';
7+
import { resendAgentActivity } from '../../server/src/agentActivityResend.js';
78
import { AgentStateStore } from '../../server/src/agentStateStore.js';
89
import { DEFAULT_MAX_CONTEXT_TOKENS, JSONL_POLL_INTERVAL_MS } from '../../server/src/constants.js';
910
import {
@@ -567,49 +568,7 @@ export function sendCurrentAgentStatuses(
567568
webview: vscode.Webview | undefined,
568569
): void {
569570
if (!webview) return;
570-
for (const [agentId, agent] of agents) {
571-
// Re-send active tools
572-
for (const [toolId, status] of agent.activeToolStatuses) {
573-
const toolName = agent.activeToolNames.get(toolId) ?? '';
574-
webview.postMessage({
575-
type: 'agentToolStart',
576-
id: agentId,
577-
toolId,
578-
status,
579-
toolName,
580-
});
581-
}
582-
// Re-send waiting status
583-
if (agent.isWaiting) {
584-
webview.postMessage({
585-
type: 'agentStatus',
586-
id: agentId,
587-
status: 'waiting',
588-
});
589-
}
590-
// Re-send team metadata. Derived teams (named background spawns) have a
591-
// name and a lead link but NO teamName, so gate on any team field.
592-
if (agent.teamName || agent.agentName || agent.isTeamLead) {
593-
webview.postMessage({
594-
type: 'agentTeamInfo',
595-
id: agentId,
596-
teamName: agent.teamName,
597-
agentName: agent.agentName,
598-
isTeamLead: agent.isTeamLead,
599-
leadAgentId: agent.leadAgentId,
600-
teamUsesTmux: agent.teamUsesTmux,
601-
});
602-
}
603-
// Re-send context usage
604-
if (agent.contextTokens > 0) {
605-
webview.postMessage({
606-
type: 'agentContextUsage',
607-
id: agentId,
608-
contextTokens: agent.contextTokens,
609-
maxContextTokens: agent.maxContextTokens,
610-
});
611-
}
612-
}
571+
resendAgentActivity((msg) => webview.postMessage(msg), agents);
613572
}
614573

615574
export function sendLayout(
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { resendAgentActivity } from '../src/agentActivityResend.js';
4+
import { AgentStateStore } from '../src/agentStateStore.js';
5+
import type { AgentState } from '../src/types.js';
6+
7+
function createTestAgent(overrides: Partial<AgentState> = {}): AgentState {
8+
return {
9+
id: 0,
10+
sessionId: 'test-session',
11+
isExternal: false,
12+
projectDir: '/test',
13+
jsonlFile: '/test/session.jsonl',
14+
fileOffset: 0,
15+
lineBuffer: '',
16+
activeToolIds: new Set(),
17+
activeToolStatuses: new Map(),
18+
activeToolNames: new Map(),
19+
activeSubagentToolIds: new Map(),
20+
activeSubagentToolNames: new Map(),
21+
backgroundAgentToolIds: new Set(),
22+
isWaiting: false,
23+
permissionSent: false,
24+
hadToolsInTurn: false,
25+
lastDataAt: 0,
26+
linesProcessed: 0,
27+
seenUnknownRecordTypes: new Set(),
28+
hookDelivered: false,
29+
contextTokens: 0,
30+
maxContextTokens: 200_000,
31+
...overrides,
32+
} as AgentState;
33+
}
34+
35+
describe('resendAgentActivity', () => {
36+
it('sends messages in order: team info, tools, waiting, context', () => {
37+
const store = new AgentStateStore();
38+
store.set(
39+
1,
40+
createTestAgent({
41+
id: 1,
42+
teamName: 'test-team',
43+
activeToolStatuses: new Map([['tool-1', 'Running']]),
44+
activeToolNames: new Map([['tool-1', 'Bash']]),
45+
isWaiting: true,
46+
contextTokens: 50_000,
47+
}),
48+
);
49+
const sent: Array<Record<string, unknown>> = [];
50+
resendAgentActivity((msg) => sent.push(msg), store);
51+
52+
expect(sent.map((m) => m.type)).toEqual([
53+
'agentTeamInfo',
54+
'agentToolStart',
55+
'agentStatus',
56+
'agentContextUsage',
57+
]);
58+
});
59+
60+
it('includes basic fields for regular tools', () => {
61+
const store = new AgentStateStore();
62+
store.set(
63+
1,
64+
createTestAgent({
65+
id: 1,
66+
activeToolStatuses: new Map([['tool-1', 'Running']]),
67+
activeToolNames: new Map([['tool-1', 'Bash']]),
68+
}),
69+
);
70+
const sent: Array<Record<string, unknown>> = [];
71+
resendAgentActivity((msg) => sent.push(msg), store);
72+
73+
expect(sent).toHaveLength(1);
74+
expect(sent[0]).toEqual({
75+
type: 'agentToolStart',
76+
id: 1,
77+
toolId: 'tool-1',
78+
status: 'Running',
79+
toolName: 'Bash',
80+
});
81+
});
82+
83+
it('handles background tools with flags and promoted skip logic', () => {
84+
const store = new AgentStateStore();
85+
// Lead with three background tools: unnamed, named spawn, and promoted
86+
store.set(
87+
1,
88+
createTestAgent({
89+
id: 1,
90+
// A teamed lead: the webview routes agentToolStart by the parent's
91+
// teamName, so the team message has to land before the tool replays or
92+
// a background spawn is routed as if the lead had no team.
93+
teamName: 'test-team',
94+
activeToolStatuses: new Map([
95+
['bg-unnamed', 'Subtask: Unnamed'],
96+
['bg-named', 'Subtask: Named'],
97+
['bg-promoted', 'Subtask: Promoted'],
98+
]),
99+
activeToolNames: new Map([
100+
['bg-unnamed', 'Agent'],
101+
['bg-named', 'Agent'],
102+
['bg-promoted', 'Agent'],
103+
]),
104+
backgroundAgentToolIds: new Set(['bg-unnamed', 'bg-named', 'bg-promoted']),
105+
teammateSpawnToolIds: new Set(['bg-named']),
106+
}),
107+
);
108+
// Promoted teammate (should cause bg-promoted to be skipped)
109+
store.set(
110+
2,
111+
createTestAgent({
112+
id: 2,
113+
leadAgentId: 1,
114+
spawnToolUseId: 'bg-promoted',
115+
agentName: 'promoted-agent',
116+
}),
117+
);
118+
const sent: Array<Record<string, unknown>> = [];
119+
resendAgentActivity((msg) => sent.push(msg), store);
120+
121+
const toolStarts = sent.filter((m) => m.type === 'agentToolStart');
122+
expect(toolStarts).toHaveLength(2); // unnamed + named, NOT promoted
123+
124+
// Team info first: the webview reads the parent's teamName to decide whether
125+
// a background agentToolStart becomes a Subtask sub-character, so a replay
126+
// that arrives before it is routed against stale team state.
127+
const teamIdx = sent.findIndex((m) => m.type === 'agentTeamInfo' && m.id === 1);
128+
const firstBgToolIdx = sent.findIndex((m) => m.type === 'agentToolStart' && m.id === 1);
129+
expect(teamIdx).toBeGreaterThanOrEqual(0);
130+
expect(teamIdx).toBeLessThan(firstBgToolIdx);
131+
132+
// Unnamed: runInBackground=true, no isTeammateSpawn. toolName is required —
133+
// without it the webview cannot recreate the Subtask after agentToolsClear.
134+
const unnamed = toolStarts.find((t) => t.toolId === 'bg-unnamed');
135+
expect(unnamed).toMatchObject({ runInBackground: true, toolName: 'Agent' });
136+
expect(unnamed?.isTeammateSpawn).toBeUndefined();
137+
138+
// Named: runInBackground=true, isTeammateSpawn=true, toolName present.
139+
const named = toolStarts.find((t) => t.toolId === 'bg-named');
140+
expect(named).toMatchObject({
141+
runInBackground: true,
142+
isTeammateSpawn: true,
143+
toolName: 'Agent',
144+
});
145+
});
146+
147+
it('sends team info for any team field trigger, omits when none present', () => {
148+
// teamName trigger
149+
let store = new AgentStateStore();
150+
store.set(1, createTestAgent({ id: 1, teamName: 'my-team' }));
151+
let sent: Array<Record<string, unknown>> = [];
152+
resendAgentActivity((msg) => sent.push(msg), store);
153+
expect(sent).toHaveLength(1);
154+
expect(sent[0]).toMatchObject({ type: 'agentTeamInfo', id: 1, teamName: 'my-team' });
155+
156+
// agentName trigger (derived team)
157+
store = new AgentStateStore();
158+
store.set(2, createTestAgent({ id: 2, agentName: 'worker-1', leadAgentId: 1 }));
159+
sent = [];
160+
resendAgentActivity((msg) => sent.push(msg), store);
161+
expect(sent).toHaveLength(1);
162+
expect(sent[0]).toMatchObject({ type: 'agentTeamInfo', id: 2, agentName: 'worker-1' });
163+
164+
// isTeamLead trigger
165+
store = new AgentStateStore();
166+
store.set(3, createTestAgent({ id: 3, isTeamLead: true }));
167+
sent = [];
168+
resendAgentActivity((msg) => sent.push(msg), store);
169+
expect(sent).toHaveLength(1);
170+
expect(sent[0]).toMatchObject({ type: 'agentTeamInfo', id: 3, isTeamLead: true });
171+
172+
// No team fields: no message
173+
store = new AgentStateStore();
174+
store.set(4, createTestAgent({ id: 4 }));
175+
sent = [];
176+
resendAgentActivity((msg) => sent.push(msg), store);
177+
expect(sent.filter((m) => m.type === 'agentTeamInfo')).toHaveLength(0);
178+
});
179+
180+
it('sends waiting status only when agent is waiting', () => {
181+
let store = new AgentStateStore();
182+
store.set(1, createTestAgent({ id: 1, isWaiting: true }));
183+
let sent: Array<Record<string, unknown>> = [];
184+
resendAgentActivity((msg) => sent.push(msg), store);
185+
expect(sent).toEqual([{ type: 'agentStatus', id: 1, status: 'waiting' }]);
186+
187+
store = new AgentStateStore();
188+
store.set(2, createTestAgent({ id: 2, isWaiting: false }));
189+
sent = [];
190+
resendAgentActivity((msg) => sent.push(msg), store);
191+
expect(sent).toHaveLength(0);
192+
});
193+
194+
it('sends context usage only when agent has tokens', () => {
195+
let store = new AgentStateStore();
196+
store.set(1, createTestAgent({ id: 1, contextTokens: 50_000, maxContextTokens: 200_000 }));
197+
let sent: Array<Record<string, unknown>> = [];
198+
resendAgentActivity((msg) => sent.push(msg), store);
199+
expect(sent).toEqual([
200+
{ type: 'agentContextUsage', id: 1, contextTokens: 50_000, maxContextTokens: 200_000 },
201+
]);
202+
203+
store = new AgentStateStore();
204+
store.set(2, createTestAgent({ id: 2, contextTokens: 0, maxContextTokens: 200_000 }));
205+
sent = [];
206+
resendAgentActivity((msg) => sent.push(msg), store);
207+
expect(sent).toHaveLength(0);
208+
});
209+
210+
it('handles edge cases: empty store and multiple agents', () => {
211+
// Empty store
212+
let store = new AgentStateStore();
213+
let sent: Array<Record<string, unknown>> = [];
214+
resendAgentActivity((msg) => sent.push(msg), store);
215+
expect(sent).toHaveLength(0);
216+
217+
// Multiple agents with different activity
218+
store = new AgentStateStore();
219+
store.set(
220+
1,
221+
createTestAgent({
222+
id: 1,
223+
activeToolStatuses: new Map([['tool-1', 'Running']]),
224+
activeToolNames: new Map([['tool-1', 'Bash']]),
225+
}),
226+
);
227+
store.set(2, createTestAgent({ id: 2, isWaiting: true }));
228+
sent = [];
229+
resendAgentActivity((msg) => sent.push(msg), store);
230+
expect(sent).toHaveLength(2);
231+
expect(sent[0]).toMatchObject({ type: 'agentToolStart', id: 1 });
232+
expect(sent[1]).toMatchObject({ type: 'agentStatus', id: 2, status: 'waiting' });
233+
});
234+
});

server/__tests__/clientMessageHandler.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,27 @@ describe('clientMessageHandler: areas + carpet wire ordering', () => {
194194
expect(existing.agents).toEqual([1]);
195195
});
196196

197+
it('replays agent activity after layoutLoaded so it lands on real characters', () => {
198+
// Two things at once, both invisible to the helper's own unit tests:
199+
// that handleWebviewReady calls the replay at all, and that it runs AFTER
200+
// layoutLoaded. The characters the replay targets only exist once the
201+
// layout flush creates them, so an earlier replay is silently dropped and
202+
// a reconnecting client shows a working agent as Idle.
203+
store.set(1, createTestAgent({ id: 1, isWaiting: true }));
204+
205+
handleClientMessage({ type: 'webviewReady' }, (m) => sent.push(m), ctx);
206+
207+
const types = sent.map((m) => m.type);
208+
const iLayout = types.indexOf('layoutLoaded');
209+
const iStatus = types.indexOf('agentStatus');
210+
211+
expect(iLayout).toBeGreaterThanOrEqual(0);
212+
expect(iStatus).toBeGreaterThanOrEqual(0);
213+
expect(iLayout).toBeLessThan(iStatus);
214+
215+
expect(sent[iStatus]).toMatchObject({ type: 'agentStatus', id: 1, status: 'waiting' });
216+
});
217+
197218
it('emits carpetTilesLoaded after wallTilesLoaded when both are present in the cache', () => {
198219
// Hex placeholders are test fixtures, not UI tokens — disable the
199220
// centralized-color rule just for this cache literal.

0 commit comments

Comments
 (0)