summaryrefslogtreecommitdiffhomepage
path: root/packages/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/api')
-rw-r--r--packages/api/src/agent-manager.ts37
-rw-r--r--packages/api/tests/agent-manager.test.ts156
2 files changed, 190 insertions, 3 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index efdd732..92caf81 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -37,6 +37,7 @@ import {
resolveApiKey,
type SkillDefinition,
type SystemChunkKind,
+ type TabStatusSnapshot,
TaskList,
updateMessage,
validateConfig,
@@ -711,10 +712,40 @@ export class AgentManager {
return this.tabAgents.get(tabId)?.status ?? "idle";
}
- getAllStatuses(): Record<string, AgentStatus> {
- const result: Record<string, AgentStatus> = {};
+ /**
+ * Snapshot of every tab the manager is currently tracking. Sent on WS
+ * connect and via GET /status so a freshly-loaded frontend can
+ * reconstruct any in-flight assistant turn without missing the chunks
+ * that arrived before its WS handshake completed.
+ *
+ * For each running tab, the snapshot includes:
+ * - status: "running"
+ * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks`
+ * (the live chunk array the streaming loop appends to). The
+ * consumer owns this copy and may mutate it freely.
+ * - currentAssistantId: the DB id of the in-flight assistant message
+ * row. The frontend aligns its local assistant message id with
+ * this so the next `done` event lands on the right message.
+ *
+ * For idle/error tabs, only `status` is present. Tabs not in
+ * `this.tabAgents` (e.g. tabs in the DB that have never been touched
+ * since server start) are absent from the returned record — the
+ * caller infers their status from the DB row (always "idle" at rest).
+ */
+ getAllStatuses(): Record<string, TabStatusSnapshot> {
+ const result: Record<string, TabStatusSnapshot> = {};
for (const [tabId, tabAgent] of this.tabAgents.entries()) {
- result[tabId] = tabAgent.status;
+ const snap: TabStatusSnapshot = { status: tabAgent.status };
+ if (tabAgent.status === "running") {
+ if (tabAgent.currentChunks) {
+ // Defensive shallow copy: callers may serialize/mutate.
+ snap.currentChunks = [...tabAgent.currentChunks];
+ }
+ if (tabAgent.currentAssistantId) {
+ snap.currentAssistantId = tabAgent.currentAssistantId;
+ }
+ }
+ result[tabId] = snap;
}
return result;
}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 71d43d8..ba14cad 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -658,4 +658,160 @@ describe("AgentManager", () => {
expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
});
+
+ // ─── getAllStatuses snapshot shape (for browser-reopen restore) ────
+ //
+ // The snapshot enriches the legacy `Record<string, AgentStatus>` shape
+ // with per-tab in-flight context so a fresh frontend can render the
+ // streaming assistant message correctly after a reload.
+
+ it("getAllStatuses returns an empty record when no tabs are tracked", () => {
+ const manager = new AgentManager();
+ expect(manager.getAllStatuses()).toEqual({});
+ });
+
+ it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => {
+ const manager = new AgentManager();
+ // Drive a full turn so the tab gets registered; default mock run
+ // settles back to idle by the time `await` resolves.
+ await manager.processMessage("tab-idle", "hi");
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-idle"]).toBeDefined();
+ expect(snap["tab-idle"]?.status).toBe("idle");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId");
+ });
+
+ it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => {
+ const manager = new AgentManager();
+ // Reach into the private map to set up a synthetic running state.
+ // Justification: there is no public API to enter a sustained
+ // "running" state without actually streaming, and we want to
+ // assert the snapshot shape — not the streaming pipeline.
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running" | "idle" | "error";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }> | null;
+ currentAssistantId: string | null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-running", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: [
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ],
+ currentAssistantId: "assistant-msg-id-7",
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-running"]).toBeDefined();
+ expect(snap["tab-running"]?.status).toBe("running");
+ expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7");
+ expect(snap["tab-running"]?.currentChunks).toEqual([
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ]);
+ });
+
+ it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }>;
+ currentAssistantId: string;
+ }
+ >;
+ };
+ const liveChunks = [{ type: "text", text: "live" }];
+ inner.tabAgents.set("tab-copy", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: liveChunks,
+ currentAssistantId: "msg-x",
+ });
+
+ const snap = manager.getAllStatuses();
+ // Mutate the snapshot's array
+ snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" });
+ // Live array must be untouched
+ expect(liveChunks).toEqual([{ type: "text", text: "live" }]);
+ });
+
+ it("getAllStatuses omits currentChunks when a running tab has none yet", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: null;
+ currentAssistantId: null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-early", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: null,
+ currentAssistantId: null,
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-early"]?.status).toBe("running");
+ expect(snap["tab-early"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId");
+ });
});