diff --git a/apps/hub/src/artifacts/activity.test.ts b/apps/hub/src/artifacts/activity.test.ts new file mode 100644 index 00000000..615f2bcf --- /dev/null +++ b/apps/hub/src/artifacts/activity.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { createSidecarEmitter } from "@intx/hub-sessions"; +import { createAgentActivity } from "./activity.ts"; + +const runId = "run_04ba732b8686649f77a2797393bafbc5"; + +test("a turn resumed after a restart, with no message run, ends on its reply", () => { + const events = createSidecarEmitter(); + const activity = createAgentActivity(events); + const send = (event: unknown) => + events.emit("agent.event", { + agentAddress: `${runId}@sunny-harbor-panda.localhost`, + sessionId: "session", + event, + }); + send({ + type: "tool.start", + seq: 1, + data: { call: { id: "call-1", name: "write_artifact", arguments: {} } }, + }); + expect(activity.of(runId).working).toBe(true); + send({ type: "connector.reply", seq: 2, data: { content: "Done." } }); + expect(activity.of(runId).working).toBe(false); + activity.dispose(); +}); diff --git a/apps/hub/src/artifacts/activity.ts b/apps/hub/src/artifacts/activity.ts index 3e52765f..27d951f8 100644 --- a/apps/hub/src/artifacts/activity.ts +++ b/apps/hub/src/artifacts/activity.ts @@ -108,7 +108,10 @@ export function createAgentActivity(events: Pick) { delete run.said; return; } - case "message.run.ended": { + // The reactor ends a message run right after it replies, but a turn resumed from an approval + // restored after a restart has no message run, so its reply is the only end it sends. + case "message.run.ended": + case "connector.reply": { const run = entry(local); run.working = false; run.since = now; diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index ae0cc6b3..bcc9b386 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -111,7 +111,13 @@ export function foldLiveRun( reload, }; } - if (event.type === "message.run.ended" || event.type === "reactor.done") { + // The reactor closes the message run right after it replies, but a turn resumed from an approval + // restored after a restart has no message run to close, so its reply is the only end it sends. + if ( + event.type === "message.run.ended" || + event.type === "connector.reply" || + event.type === "reactor.done" + ) { return { run: { ...run, ended: true, running: false, endedAt: Date.now() }, reload, diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index d62c89d2..2cb3fd09 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -188,6 +188,18 @@ describe("conversation transcript", () => { expect(awaitingCommit(done, [])).toBe(true); }); + test("a turn resumed from an approval after a restart ends on its reply", () => { + const resumed = [ + { type: "message.correlated" }, + { type: "inference.start" }, + { type: "inference.done" }, + ].reduce((run, event) => foldLiveRun(run, event, null).run, emptyLiveRun); + expect(resumed.running).toBe(true); + const replied = foldLiveRun(resumed, { type: "connector.reply" }, null).run; + expect(replied.running).toBe(false); + expect(replied.ended).toBe(true); + }); + test("a specialist's report joins its hand-off as one message and a long run of lone ones folds", () => { const summary = "Wrote the limits for your weather app, covering offline use and units.";