From 14a4340b14f982f382b917f4a3e49f41447367bd Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:48:40 +0000 Subject: [PATCH 1/2] feat(desktop,host): a shown transcript tab hears its pages pushed, with no read loop The tab of an agent, a worker, or a task's planning worker now follows its transcript while it is shown and lets it go once it is hidden. It joins each page pushed on `app:transcript` by message id, so the window no longer runs a read loop or keeps a cursor. A page that couldn't be read offers Try again, which follows the transcript again. `codingAgents.messages` and `tasks.messages` with a call still serve Copy transcript, but now read at once (`wait=none`). The host's held read (`transcriptPast`) is retired, along with `followsAgent`, which only that loop used. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/gateway/src/protocol.ts | 4 +- packages/host/src/acts.ts | 2 +- .../host/src/compose-coding-agents.test.ts | 168 ------------- packages/host/src/compose-coding-agents.ts | 33 ++- packages/host/src/compose-tasks.test.ts | 59 +---- packages/host/src/compose-tasks.ts | 10 +- packages/host/src/transcript-follow.ts | 110 ++------- .../src/desktop/desktop-agent-page.test.ts | 16 +- .../panel/src/desktop/origin-senders.test.ts | 28 +-- packages/panel/src/desktop/side-panel.tsx | 7 +- .../src/tasks/agent-arrived-lines.test.ts | 16 +- .../panel/src/tasks/agent-senders.test.ts | 18 +- packages/panel/src/tasks/agent-tab.tsx | 15 +- .../panel/src/tasks/agent-turn-foot.test.ts | 14 +- .../src/tasks/coding-agent-model.test.ts | 15 -- .../panel/src/tasks/coding-agent-model.ts | 11 +- .../panel/src/tasks/continue-agent.test.ts | 14 +- packages/panel/src/tasks/fork-action.test.ts | 16 +- .../panel/src/tasks/message-links.test.ts | 34 +-- packages/panel/src/tasks/replay-card.test.ts | 18 +- .../src/tasks/reply-to-selection.test.ts | 30 +-- packages/panel/src/tasks/server-card.test.ts | 20 +- packages/panel/src/tasks/subagent-tab.test.ts | 18 +- .../src/tasks/use-agent-transcript.test.ts | 135 +++++++++++ .../panel/src/tasks/use-agent-transcript.ts | 225 +++++++++--------- packages/panel/src/tasks/use-coding-agents.ts | 12 +- packages/panel/src/tasks/worker-mac.test.ts | 18 +- packages/panel/src/testing/tasks-control.ts | 43 +++- 28 files changed, 452 insertions(+), 657 deletions(-) create mode 100644 packages/panel/src/tasks/use-agent-transcript.test.ts diff --git a/packages/gateway/src/protocol.ts b/packages/gateway/src/protocol.ts index 850937a4ba..ecff79da88 100644 --- a/packages/gateway/src/protocol.ts +++ b/packages/gateway/src/protocol.ts @@ -46,7 +46,7 @@ export const GATEWAY_METHOD = { TASKS_WORK_EARLIER: "tasks.workEarlier", /** The page of the open task's conversation before the oldest turn held, read from the service where an older one stands; the view takes it before the rest, and the held read's cursor stays. */ TASKS_CONVERSATION_EARLIER: "tasks.conversationEarlier", - /** One page of a task's conversation as an agent's transcript is read, or a brain worker's under the call that started it, held open by the service while a turn or the worker runs; answered to the caller, never held in the view. */ + /** One page of a task's conversation as an agent's transcript is read, held open by the service while a turn runs; or a brain worker's under the call that started it, read at once; answered to the caller, never held in the view. */ TASKS_MESSAGES: "tasks.messages", /** A task's planning worker's transcript followed and let go, on the same terms as `CODING_AGENTS_TRANSCRIPT_FOLLOW`. */ TASKS_WORKER_TRANSCRIPT_FOLLOW: "tasks.workerTranscriptFollow", @@ -137,7 +137,7 @@ export const GATEWAY_METHOD = { CODING_AGENTS_WORKSPACE_LIST: "codingAgents.workspaceList", /** A coding agent started on one task under the panel's own key; the agent, or why none started. */ CODING_AGENTS_START: "codingAgents.start", - /** One agent's transcript past a cursor, held open by the service while the agent runs. */ + /** One agent's transcript past a cursor, read at once, as Copy transcript reads it; a shown tab follows it instead (`CODING_AGENTS_TRANSCRIPT_FOLLOW`). */ CODING_AGENTS_MESSAGES: "codingAgents.messages", /** * One agent's transcript, or one of its workers', followed from now until diff --git a/packages/host/src/acts.ts b/packages/host/src/acts.ts index 24b35bc559..b3427a1d19 100644 --- a/packages/host/src/acts.ts +++ b/packages/host/src/acts.ts @@ -144,7 +144,7 @@ export const ACT_KIND = { TASKS_WORK_EARLIER: "tasks.workEarlier", /** The Call tab scrolled back to its oldest turn: the open task's page of conversation before it, read from the service; the view arrives on the document. */ TASKS_CONVERSATION_EARLIER: "tasks.conversationEarlier", - /** One page of a task's conversation, or a brain worker's under the call that started it, held open by the service while it runs; answered to the tab, never on the document. */ + /** One page of a task's conversation, held open by the service while it runs, or a brain worker's under the call that started it, read at once; answered to the tab, never on the document. */ TASKS_MESSAGES: "tasks.messages", /** A File tab's window of a file of the open task's repository, read from GitHub through the service and coloured by main; answered to the tab, never on the document. */ TASKS_FILE: "tasks.file", diff --git a/packages/host/src/compose-coding-agents.test.ts b/packages/host/src/compose-coding-agents.test.ts index 2b18010cfb..5ed1de6378 100644 --- a/packages/host/src/compose-coding-agents.test.ts +++ b/packages/host/src/compose-coding-agents.test.ts @@ -945,9 +945,7 @@ it.effect("each method asks the service once and answers what it said, whole", ( `list:${TASK_ID}:archived`, "workspace:workspace-1:acme/relay", `start:${TASK_ID}:press-1:default`, - `transcript-live:${AGENT_ID}`, `messages-now:${AGENT_ID}:${CODING_AGENT_CURSOR_START}`, - `transcript-live:${AGENT_ID}:call-7`, `worker-now:${AGENT_ID}:call-7:${CODING_AGENT_CURSOR_START}`, `message:${AGENT_ID}:send-1:Also expire them after a week.`, `choose:${AGENT_ID}:openai/gpt-6.1-sol:low`, @@ -1464,172 +1462,6 @@ function windowPage(page: TranscriptNowAnswer) { return rest; } -it.effect( - "a running agent's transcript is held open on its Realtime subscription: the page read at once while it says something, then again only when the statements move, the empty page once the hold ends, and never a sealed statement", - () => - Effect.gen(function* () { - const { call, asked, sealTranscript, pageNow: script, realtime } = yield* subject(); - sealTranscript(SEALED_TRANSCRIPT); - const said = pageNow("1:0", { holds: false, text: "Reading the invites." }); - script(said); - const first = yield* call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { - agentId: AGENT_ID, - after: CODING_AGENT_CURSOR_START, - }); - assert.deepEqual(first, windowPage(said)); - yield* settled; - assert.deepEqual(realtime.opened, [SEALED_TRANSCRIPT.url]); - transcriptRows(realtime, 1); - yield* settled; - - // Nothing stands past the cursor: the read is held, and the service asked nothing more until a statement moves. - const nothing = pageNow("1:0", { holds: true }); - const moved = pageNow("2:0", { holds: false, text: "Writing the expiry." }); - script(nothing, moved); - const next = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { agentId: AGENT_ID, after: "1:0" }), - ); - yield* settled; - yield* TestClock.adjust("5 seconds"); - yield* settled; - assert.deepEqual(asked.filter((ask) => ask.startsWith("messages-now")).length, 2); - transcriptRows(realtime, 2); - yield* settled; - assert.deepEqual(yield* Fiber.join(next), windowPage(moved)); - - // Nothing moves within the hold: the page as it stands once the hold ends. - script(pageNow("2:0", { holds: true })); - const held = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { agentId: AGENT_ID, after: "2:0" }), - ); - yield* settled; - yield* TestClock.adjust("20 seconds"); - yield* settled; - assert.deepEqual(yield* Fiber.join(held), windowPage(pageNow("2:0", { holds: true }))); - - assert.deepEqual(asked, [ - `transcript-live:${AGENT_ID}`, - `messages-now:${AGENT_ID}:${CODING_AGENT_CURSOR_START}`, - `messages-now:${AGENT_ID}:1:0`, - `messages-now:${AGENT_ID}:1:0`, - `messages-now:${AGENT_ID}:2:0`, - `messages-now:${AGENT_ID}:2:0`, - ]); - assert.deepEqual(realtime.opened, [SEALED_TRANSCRIPT.url]); - assert.ok(!JSON.stringify([first, moved]).includes("capability")); - }), -); - -it.effect( - "a worker's transcript is followed under its own seal, a subscription that stops has the read read again at once and is sealed and opened again after a pause, and a sign-out lets every subscription go", - () => - Effect.gen(function* () { - const { call, asked, sealTranscript, pageNow: script, realtime, reset } = yield* subject(); - sealTranscript(SEALED_TRANSCRIPT); - const said = pageNow("2:0", { holds: false, text: "Writing the expiry." }); - script(pageNow("1:0", { holds: true }), said); - const read = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { - agentId: AGENT_ID, - after: CODING_AGENT_CURSOR_START, - callId: "call-7", - }), - ); - yield* settled; - realtime.fail("messages"); - yield* settled; - assert.deepEqual(yield* Fiber.join(read), windowPage(said)); - assert.deepEqual(realtime.closed, [SEALED_TRANSCRIPT.url]); - - yield* TestClock.adjust("1 second"); - yield* settled; - assert.deepEqual(asked, [ - `transcript-live:${AGENT_ID}:call-7`, - `worker-now:${AGENT_ID}:call-7:${CODING_AGENT_CURSOR_START}`, - `worker-now:${AGENT_ID}:call-7:${CODING_AGENT_CURSOR_START}`, - `transcript-live:${AGENT_ID}:call-7`, - ]); - assert.deepEqual(realtime.opened, [SEALED_TRANSCRIPT.url, SEALED_TRANSCRIPT.url]); - - yield* reset; - yield* settled; - assert.deepEqual(realtime.closed, [SEALED_TRANSCRIPT.url, SEALED_TRANSCRIPT.url]); - }), -); - -it.effect( - "a transcript the account does not reach is not found, and one the service seals nothing for is sealed again each second, then read live", - () => - Effect.gen(function* () { - const refused = yield* subject(); - refused.sealTranscript({ failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); - assert.deepEqual( - yield* refused.call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { - agentId: AGENT_ID, - after: CODING_AGENT_CURSOR_START, - }), - { failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }, - ); - - const unsealed = yield* subject(); - const said = pageNow("1:0", { holds: false, text: "Reading the invites." }); - unsealed.pageNow(said); - const read = yield* Effect.forkChild( - unsealed.call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { - agentId: AGENT_ID, - after: CODING_AGENT_CURSOR_START, - }), - ); - yield* settled; - yield* TestClock.adjust("1 second"); - yield* settled; - unsealed.sealTranscript(SEALED_TRANSCRIPT); - yield* TestClock.adjust("1 second"); - yield* settled; - assert.deepEqual(yield* Fiber.join(read), windowPage(said)); - assert.deepEqual(unsealed.asked, [ - `transcript-live:${AGENT_ID}`, - `transcript-live:${AGENT_ID}`, - `transcript-live:${AGENT_ID}`, - `messages-now:${AGENT_ID}:${CODING_AGENT_CURSOR_START}`, - ]); - assert.deepEqual(unsealed.realtime.opened, [SEALED_TRANSCRIPT.url]); - }), -); - -it.effect( - "a read the service says only the clock may end is read again at the edge it names, though no row moved, rather than at the hold's end", - () => - Effect.gen(function* () { - const { call, asked, sealTranscript, pageNow: script, realtime } = yield* subject(); - sealTranscript(SEALED_TRANSCRIPT); - const failed = { ...pageNow("0:0", { holds: false }), status: CODING_AGENT_STATUS.FAILED }; - const starting = { - ...pageNow("0:0", { holds: true }), - status: CODING_AGENT_STATUS.STARTING, - wakeIn: 3_000, - }; - script(starting, starting, failed); - const read = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_MESSAGES, { - agentId: AGENT_ID, - after: CODING_AGENT_CURSOR_START, - }), - ); - yield* settled; - transcriptRows(realtime, 0, 0); - yield* settled; - // The first rows landing read the page again; the edge is still ahead, so it holds. - yield* TestClock.adjust("2999 millis"); - yield* settled; - assert.equal(read.pollUnsafe(), undefined); - yield* TestClock.adjust("1 millis"); - yield* settled; - assert.deepEqual(yield* Fiber.join(read), windowPage(failed)); - assert.equal(asked.filter((ask) => ask.startsWith("messages-now")).length, 3); - }), -); - const FOLLOWED = { kind: TRANSCRIPT_KIND.AGENT, agentId: AGENT_ID } as const; it.effect( diff --git a/packages/host/src/compose-coding-agents.ts b/packages/host/src/compose-coding-agents.ts index 367267c120..0dd411cdb4 100644 --- a/packages/host/src/compose-coding-agents.ts +++ b/packages/host/src/compose-coding-agents.ts @@ -90,23 +90,23 @@ import { type TranscriptFollows, TranscriptKey, transcriptFollows, - transcriptPast, + windowsPage, } from "./transcript-follow.js"; /** * compose-coding-agents.ts -- a task's coding agents, asked of the service on the window's behalf: the models, the account's default, its own provider keys, and its plan template, the agents, one started, one's transcript, one messaged, one's model changed, one stopped, what one published, a clip dictated into an agent's box heard as words, the task's inbox, one item of it answered, what waits across the account, and the workspace's chat, read and written. * - * Nothing is held here but the account inbox's follower. The window holds the agents it draws and the - * cursor it reads from, asks when a task opens, when it starts or stops an - * agent, and while an agent's tab shows its running transcript, and every - * ask is one call of the service answered whole. The transcript read is - * one held open while the agent runs and nothing new stands, here on a - * Neon Realtime subscription where the service seals one - * (`transcript-follow.ts`) and by the service where it does not, and the - * inbox read another, held while nothing in it changed, and the - * chat read a third, held while nothing in the workspace's chat moved, so each of - * the window's loops is one held read after another rather than a clock of - * its own, and a loop ends when the window stops asking. What waits across + * Nothing is held here but the account inbox's follower and the transcripts + * main follows. The window holds the agents it draws, asks when a task + * opens and when it starts or stops an agent, and every ask is one call of + * the service answered whole. A shown tab's transcript is followed rather + * than asked: main follows it for the windows drawing it, and its pages are + * told as events, read on a Neon Realtime subscription + * (`transcript-follow.ts`); a read past a cursor is answered at once, as + * Copy transcript asks it. The inbox read is held while nothing in it + * changed, and the chat read while nothing in the workspace's chat moved, + * so each of those loops is one held read after another rather than a + * clock of its own, and a loop ends when the window stops asking. What waits across * the account is followed rather than asked: main follows it while an * account is signed in (`agent-notices.ts`), and it is heard as events, * over Neon Realtime (`account-inbox-follow.ts`), @@ -401,12 +401,9 @@ function composed( params, "reading an agent's transcript names the agent and a cursor", ); - const answer = gate() - ? yield* transcriptPast(transcripts, new TranscriptKey({ agentId, callId }), { - now: asked(client.messagesNow(agentId, after, callId)), - }) - : UNANSWERED; - return carried(answer); + // Note that the page is read at once, since Copy transcript waits on no hold. + const answer = yield* asked(client.messagesNow(agentId, after, callId)); + return carried(windowsPage(answer)); }), [GATEWAY_METHOD.CODING_AGENTS_TRANSCRIPT_FOLLOW]: (params) => Effect.gen(function* () { diff --git a/packages/host/src/compose-tasks.test.ts b/packages/host/src/compose-tasks.test.ts index 0c2edf89ba..32b5d1fcd7 100644 --- a/packages/host/src/compose-tasks.test.ts +++ b/packages/host/src/compose-tasks.test.ts @@ -2992,61 +2992,22 @@ function workerPage( } it.effect( - "a planning worker's transcript is held here on its live statements: read at once, again only as they move, and answered once the service says its held read would not hold", - () => - Effect.gen(function* () { - const service = fakeService([task(INVITES, "Teammate invitations", "# Draft", 10)]); - const { call, conversations, realtime } = yield* subject(service); - conversations.workerSealed = SEALED_WORKER; - const read = yield* Effect.forkChild( - call(GATEWAY_METHOD.TASKS_MESSAGES, { taskId: INVITES, callId: "call-0-0", after: "2:0" }), - ); - yield* conversations.answerWorker(workerPage([], CODING_AGENT_STATUS.RUNNING, true)); - yield* conversations.answerWorker(workerPage([], CODING_AGENT_STATUS.RUNNING, true)); - yield* realtime.deliver(1); - // The same rows again move nothing; new ones read the page again. - yield* realtime.deliver(1); - yield* TestClock.adjust(Duration.seconds(5)); - assert.equal(conversations.workerReads.length, 2); - yield* conversations.answerWorker( - workerPage([journal("worker-1", "Compared them.")], CODING_AGENT_STATUS.COMPLETED, false), - ); - yield* realtime.deliver(2); - - assert.deepEqual( - yield* Fiber.join(read), - conversationPage( - [journal("worker-1", "Compared them.")], - "3:0", - CODING_AGENT_STATUS.COMPLETED, - ), - ); - assert.deepEqual(conversations.workerReads, [ - `${INVITES}:call-0-0:2:0`, - `${INVITES}:call-0-0:2:0`, - `${INVITES}:call-0-0:2:0`, - ]); - assert.deepEqual(conversations.asks, []); - }), -); - -it.effect( - "a planning worker's transcript the service seals nothing for is sealed again each second, then read live", + "a planning worker's transcript is read at once past the cursor, as Copy transcript asks it, with the service's word on holding left in the host", () => Effect.gen(function* () { const service = fakeService([task(INVITES, "Teammate invitations", "# Draft", 10)]); const { call, conversations } = yield* subject(service); - const read = yield* Effect.forkChild( - call(GATEWAY_METHOD.TASKS_MESSAGES, { taskId: INVITES, callId: "call-0-0", after: "2:0" }), - ); - yield* TestClock.adjust(Duration.seconds(1)); - assert.deepEqual(conversations.workerReads, []); - conversations.workerSealed = SEALED_WORKER; - yield* TestClock.adjust(Duration.seconds(1)); - const page = workerPage([], CODING_AGENT_STATUS.COMPLETED, false); + const page = workerPage([], CODING_AGENT_STATUS.RUNNING, true); yield* conversations.answerWorker(page); const { holds: _holds, ...answered } = page; - assert.deepEqual(yield* Fiber.join(read), answered); + assert.deepEqual( + yield* call(GATEWAY_METHOD.TASKS_MESSAGES, { + taskId: INVITES, + callId: "call-0-0", + after: "2:0", + }), + answered, + ); assert.deepEqual(conversations.workerReads, [`${INVITES}:call-0-0:2:0`]); assert.deepEqual(conversations.asks, []); }), diff --git a/packages/host/src/compose-tasks.ts b/packages/host/src/compose-tasks.ts index 69771358ba..3bccf2869b 100644 --- a/packages/host/src/compose-tasks.ts +++ b/packages/host/src/compose-tasks.ts @@ -145,7 +145,7 @@ import { followTranscript, type TranscriptFollows, transcriptFollows, - transcriptPast, + windowsPage, } from "./transcript-follow.js"; /** @@ -1339,19 +1339,19 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct }); } const { taskId, callId, ...page } = read.success; - // A planning worker's transcript is held here on its live statements. + // A planning worker's page is read at once, since Copy transcript waits on no hold. const answer = callId === undefined ? yield* served(conversations.taskMessages(taskId, page)) - : yield* transcriptPast(workerTranscripts, new TaskWorkerKey({ taskId, callId }), { - now: served( + : windowsPage( + yield* served( conversations.taskWorkerMessagesNow( taskId, callId, page.after ?? CODING_AGENT_CURSOR_START, ), ), - }); + ); return carried(answer); }), // A planning worker's transcript is followed for main, each page told as an event. diff --git a/packages/host/src/transcript-follow.ts b/packages/host/src/transcript-follow.ts index 6e89aa3918..be86a690db 100644 --- a/packages/host/src/transcript-follow.ts +++ b/packages/host/src/transcript-follow.ts @@ -22,40 +22,28 @@ import { import { LiveBroken, type LiveClient, liveRows } from "./live-follow.js"; /** - * transcript-follow.ts -- a coding agent's transcript, or one of its workers', read by the window as before but held open here on a Neon Realtime subscription rather than by the service on a clock. + * transcript-follow.ts -- a coding agent's transcript, or one of its workers', or a task's planning worker's, followed here on a Neon Realtime subscription for the windows that draw it, each page told as it is read. * - * The window still asks with the cursor it was last answered, and is - * answered with the page past it at once where there is something on it, or - * the agent has ended, or else as soon as there is, or with the empty page - * once the hold ends, as the service's held read answered it. What changed - * is who waits: the host subscribes to the statements the transcript's - * pages are read from (`transcript-live.ts` on the service) the first time - * a window asks, reads the page at once from the service, and while the - * service says its held read would have held on it, reads it again only - * when those statements move, rather than the service looking every half - * second. The page itself, its usage priced from the model catalog among - * it, is still the service's. The subscription is let go a while after the - * last read let go of it. A seal the service did not answer is asked for - * again every `RESEAL`, and a subscription that stops is marked broken, - * which has the reader read at once, and sealed and opened again after - * `RESEAL`; one the account does not reach is not found, as the held read - * said. + * The host subscribes to the statements the transcript's pages are read + * from (`transcript-live.ts` on the service) when main first follows it, + * and reads the page at once from the service: from the start, then the + * next past it at once while the last said something, and otherwise again + * only when those statements move, rather than the service looking every + * half second. The page itself, its usage priced from the model catalog + * among it, is still the service's. Where the service says the clock alone + * may move the page in so long, as an agent passing the grace its first + * turn had to land in or the window of a title it waits on closing, which + * no row says, the page is read again then too; the delay is the + * service's own, so the two clocks never meet. * - * Where the service says the clock alone may end the hold in so long, as - * an agent passing the grace its first turn had to land in or the window of - * a title it waits on closing, which no row says, the page is read again - * then too; the delay is the service's own, so the two clocks never meet. - * The hold ends with one more read of the page, as the service's own does. - * - * A transcript main follows for the windows that draw it (`followTranscript`) - * is read on the same subscription with no hold at all: every page is told - * as it is read, from the start, and the next is read whenever the - * statements move or the clock reaches the edge the service named. + * The subscription is let go a while after the last follow let go of it. A + * seal the service did not answer is asked for again every `RESEAL`, and + * a subscription that stops is marked broken, which has the follow read at + * once, and sealed and opened again after `RESEAL`; one the account does + * not reach is told not found. */ -/** How long a read is held while nothing stands past its cursor, as the service held it. */ -const HOLD = Duration.seconds(20); -/** How long a subscription no read holds is kept, so the window's next read finds it standing. */ +/** How long a subscription no follow holds is kept, so a tab shown again finds it standing. */ const IDLE = Duration.seconds(30); /** How long after a seal the service did not answer, or a subscription that stopped, the transcript is sealed again. */ const RESEAL = Duration.seconds(1); @@ -89,13 +77,7 @@ export interface TranscriptFollowDependencies { readonly now: () => number; } -/** How one read of the transcript is asked. */ -export interface TranscriptReads { - /** The page past the cursor at once, with whether the service's held read would have held on it. */ - readonly now: Effect.Effect; -} - -/** The subscriptions the windows' reads share, each held while read and a while after. */ +/** The subscriptions the followed transcripts share, each held while followed and a while after. */ export type TranscriptFollows = RcMap.RcMap; /** Every part's rows as one text, in no order of Realtime's, so only a change to a row moves it; held here alone, so it needs no hash, and the host loads where no `node:` module does. */ @@ -169,7 +151,7 @@ export function transcriptFollows( } /** The page as the window reads it: the service's word on whether it would have held left here. */ -function windowsPage( +export function windowsPage( answer: TranscriptNowAnswer | CodingAgentMessagesAnswerView, ): CodingAgentMessagesAnswerView { if (!("holds" in answer)) return answer; @@ -184,58 +166,6 @@ function movedFrom(was: LiveState): (state: LiveState) => boolean { (state.kind === "standing" && was.kind === "standing" && state.digest !== was.digest); } -/** - * The page read at once, again each time the statements move, their - * subscription stops or opens again, or the clock reaches the edge the - * service named, while the service says its held read would hold on it. - * Never ends of itself while the page holds. - */ -function readUntilSaid( - state: SubscriptionRef.SubscriptionRef, - reads: TranscriptReads, -): Effect.Effect { - return Effect.gen(function* () { - while (true) { - const was = yield* SubscriptionRef.get(state); - const answer = yield* reads.now; - if (!("holds" in answer) || !answer.holds) return windowsPage(answer); - const moved = SubscriptionRef.changes(state).pipe( - Stream.filter(movedFrom(was)), - Stream.runHead, - ); - // Note that an edge only the clock reaches is waited for beside the rows, since no row says it. - yield* answer.wakeIn === undefined - ? moved - : Effect.race(moved, Effect.sleep(Duration.millis(answer.wakeIn))); - } - }); -} - -/** - * The transcript past the reader's cursor: at once where something stands - * past it or the agent has ended, or as soon as something does within the - * hold, or the page as it stands once the hold ends, a transcript not yet - * sealed among them. - */ -export function transcriptPast( - follows: TranscriptFollows, - key: Key, - reads: TranscriptReads, -): Effect.Effect { - return Effect.scoped( - Effect.gen(function* () { - const said = yield* Effect.gen(function* () { - const standing = yield* RcMap.get(follows, key); - if (standing.kind === "refused") { - return { failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND } as const; - } - return yield* readUntilSaid(standing.state, reads); - }).pipe(Effect.timeoutOption(HOLD)); - return Option.isSome(said) ? said.value : windowsPage(yield* reads.now); - }), - ); -} - /** How one followed transcript's next page is read: the page past the cursor at once, with whether the service's held read would have held on it. */ export type TranscriptPageAfter = ( after: string, diff --git a/packages/panel/src/desktop/desktop-agent-page.test.ts b/packages/panel/src/desktop/desktop-agent-page.test.ts index a9d1650383..061c311c59 100644 --- a/packages/panel/src/desktop/desktop-agent-page.test.ts +++ b/packages/panel/src/desktop/desktop-agent-page.test.ts @@ -12,6 +12,7 @@ import { CODING_AGENT_CALL_FAILURE, type CodingAgentMessagesAnswerView, type CodingAgentPageAnswerView, + type CodingAgentTranscriptChanged, } from "@luke/features/coding-agent-view"; import { CHECK_SUMMARY, @@ -148,8 +149,10 @@ let archives: { agentId: string; archived: boolean }[] = []; let archiveRefused = false; /** What the agent's workspace's members read answers; none refuses it. */ let members: readonly WorkspaceMember[] | undefined; -/** What the next read of the agent's transcript answers; none refuses it. */ +/** What the agent's transcript is pushed as once its tab follows it, and what a read of it answers once; none refuses it. */ let transcript: CodingAgentMessagesAnswerView | undefined; +/** Where the window hears its followed transcripts pushed. */ +let pushed: ((changed: CodingAgentTranscriptChanged) => void) | undefined; /** Every act the page told or asked, in order. */ let told: AnyAct[] = []; /** What a read of what the agent published answers; none refuses it. */ @@ -176,6 +179,7 @@ beforeEach(() => { archiveRefused = false; members = undefined; transcript = undefined; + pushed = undefined; told = []; published = undefined; earlier = 0; @@ -185,6 +189,12 @@ beforeEach(() => { workspace = []; gitHub = new Map(); fakeActs({ + onTranscript: (listener) => { + pushed = listener; + return () => { + pushed = undefined; + }; + }, act: (request: AnyAct): Promise => { if (request.kind === ACT_KIND.AUTOMATIONS_LIST) { return Promise.resolve({ @@ -200,6 +210,10 @@ beforeEach(() => { ); } told.push(request); + if (request.kind === ACT_KIND.CODING_AGENTS_TRANSCRIPT_FOLLOW) { + if (transcript !== undefined) pushed?.({ transcript: request.payload, page: transcript }); + return Promise.resolve({ status: ACT_OUTCOME_STATUS.DONE, value: undefined }); + } if (request.kind === ACT_KIND.TASKS_SKILLS) { const value = { skills: { diff --git a/packages/panel/src/desktop/origin-senders.test.ts b/packages/panel/src/desktop/origin-senders.test.ts index 879588eda2..8d729a9a12 100644 --- a/packages/panel/src/desktop/origin-senders.test.ts +++ b/packages/panel/src/desktop/origin-senders.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentMessage, @@ -22,7 +18,12 @@ import { SIDE_PANEL_TAB, type SidePanelTab } from "../tasks/use-side-panel.js"; import { NO_LOOKS } from "../tasks/use-task-looks.js"; import { onRegistry } from "../testing/copy-feedback.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; -import { codingAgentsControl, tasksControl, UNPINNED_SUMMARY } from "../testing/tasks-control.js"; +import { + codingAgentsControl, + pushedTranscripts, + tasksControl, + UNPINNED_SUMMARY, +} from "../testing/tasks-control.js"; import { DesktopTasks } from "./desktop-tasks.js"; /** @@ -85,8 +86,6 @@ async function mount(input: { side?: SidePanelTab | undefined; }): Promise<{ container: HTMLElement; chosen: (MainTab | SidePanelTab)[] }> { const chosen: (MainTab | SidePanelTab)[] = []; - // Note that only the first read answers, because a read past the cursor waits on the service. - const read = new Set(); const control = tasksControl({ me: input.reader, page: TASKS_PAGE.DOCUMENT, @@ -110,19 +109,14 @@ async function mount(input: { agents: [AGENT], agentIds: [AGENT.id], me: input.reader, - readTranscript: ({ callId }): Promise => { - const key = callId ?? "agent"; - if (read.has(key)) { - return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - } - read.add(key); - return Promise.resolve({ - messages: callId === undefined ? [BRIEF] : [], + transcripts: pushedTranscripts((transcript) => [ + { + messages: transcript.callId === undefined ? [BRIEF] : [], cursor: "1:0", status: CODING_AGENT_STATUS.COMPLETED, title: null, - }); - }, + }, + ]), }), }); const container = document.body.appendChild(document.createElement("div")); diff --git a/packages/panel/src/desktop/side-panel.tsx b/packages/panel/src/desktop/side-panel.tsx index 8dc881cae8..d345647ba8 100644 --- a/packages/panel/src/desktop/side-panel.tsx +++ b/packages/panel/src/desktop/side-panel.tsx @@ -1,5 +1,6 @@ import type { Board } from "@luke/features/board-wire"; import type { BrowserPage } from "@luke/features/browser-acts"; +import { TRANSCRIPT_KIND } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentMessage, @@ -1528,8 +1529,6 @@ function TabContent({ ); } if (isSubagentTab(tab)) { - // A brain's worker's tab is an agent's worker's own, reading its session under the task. - const tasksWorkers = { ...agents, readTranscript: work.readWorker }; if (tab.delegation !== undefined) { const parent: ToolTarget = { kind: TOOL_TARGET.AGENT, agent: tab.delegation.agent }; const { subagent } = tab; @@ -1569,7 +1568,9 @@ function TabContent({ callId={tab.subagent} task={worker.task} status={workerStatus(worker)} - control={tasksWorkers} + control={agents} + // A brain's worker's tab is an agent's worker's own, following its session under the task. + transcriptKey={{ kind: TRANSCRIPT_KIND.TASK_WORKER, taskId, callId: tab.subagent }} shown={shown} // Where a brain's worker stands is the plan's pages' to say, beside its call. onStatus={() => undefined} diff --git a/packages/panel/src/tasks/agent-arrived-lines.test.ts b/packages/panel/src/tasks/agent-arrived-lines.test.ts index 1986795a22..1374c1904e 100644 --- a/packages/panel/src/tasks/agent-arrived-lines.test.ts +++ b/packages/panel/src/tasks/agent-arrived-lines.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_SENDER, @@ -23,7 +19,7 @@ import { afterEach, test } from "vitest"; import { MemberDirectory } from "../member-directory.js"; import { fakeActPort, fakeAppBridge } from "../testing/app-bridge.js"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab } from "./agent-tab.js"; /** @@ -132,16 +128,12 @@ async function drawnTab( [ACT_KIND.WORKSPACE_MEMBERS_READ]: () => Promise.resolve([{ ...DEAN, email: "dean@example.com", role: WORKSPACE_ROLE.OWNER }]), }); - // Note that only the first read answers, because a read past the cursor waits on the service, and one answered at once would be read again without end. - let reads = 0; const control = codingAgentsControl({ agents: [agentOf(status)], me: DEAN.userId, - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ messages: [...messages], cursor: "9:0", status, title: null }); - }, + transcripts: pushedTranscripts(() => [ + { messages: [...messages], cursor: "9:0", status, title: null }, + ]), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/tasks/agent-senders.test.ts b/packages/panel/src/tasks/agent-senders.test.ts index 638894d5d2..9794aff92e 100644 --- a/packages/panel/src/tasks/agent-senders.test.ts +++ b/packages/panel/src/tasks/agent-senders.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_SENDER, @@ -30,7 +26,7 @@ import { MemberDirectory } from "../member-directory.js"; import { type AnyAct, type AnyActOutcome, fakeActs, fakeAppBridge } from "../testing/app-bridge.js"; import { fakeAppPlatform, QUIET_PANEL } from "../testing/app-platform.js"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab } from "./agent-tab.js"; import { OpenLinkProvider } from "./use-open-link.js"; @@ -167,21 +163,17 @@ async function drawnFor(reader: string, opened: LinkPlace[] = []): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [...MESSAGES], cursor: "7:0", status: CODING_AGENT_STATUS.COMPLETED, title: null, - }); - }, + }, + ]), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/tasks/agent-tab.tsx b/packages/panel/src/tasks/agent-tab.tsx index 150673d8ab..9defc43273 100644 --- a/packages/panel/src/tasks/agent-tab.tsx +++ b/packages/panel/src/tasks/agent-tab.tsx @@ -1,4 +1,5 @@ import { AGENT_TOOL } from "@luke/features/agent-tools"; +import { type CodingAgentTranscriptKey, TRANSCRIPT_KIND } from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -1674,10 +1675,9 @@ export function AgentTab({ }): React.JSX.Element { const { tell } = useAct(); const transcript = useAgentTranscript({ - agentId: agent.id, - status: agent.status, + transcript: { kind: TRANSCRIPT_KIND.AGENT, agentId: agent.id }, shown, - read: control.readTranscript, + follower: control.transcripts, onStatus: control.onStatus, onTitle: control.onTitle, }); @@ -1877,10 +1877,13 @@ export function AgentSubagentTab({ onStatus, onHeld, sender, + transcriptKey, }: { agentId: string; callId: string; task: string; + /** The transcript the tab follows, where it is a task's planning worker's rather than the agent's worker's under `agentId` and `callId`. */ + transcriptKey?: CodingAgentTranscriptKey | undefined; /** Who handed the worker its task, named under its card: the agent that delegated it, which opens its tab; none where the panel names nobody. */ sender?: ReactNode; status: CodingAgentStatus; @@ -1892,11 +1895,9 @@ export function AgentSubagentTab({ onHeld: (callId: string, held: HeldTranscript) => void; }): React.JSX.Element { const transcript = useAgentTranscript({ - agentId, - callId, - status, + transcript: transcriptKey ?? { kind: TRANSCRIPT_KIND.AGENT, agentId, callId }, shown, - read: control.readTranscript, + follower: control.transcripts, onStatus: (_agentId, next) => onStatus(next.status), // A worker's page names the worker's conversation, which is no title of the agent's. onTitle: () => undefined, diff --git a/packages/panel/src/tasks/agent-turn-foot.test.ts b/packages/panel/src/tasks/agent-turn-foot.test.ts index 4dab177d85..e3d048ca3a 100644 --- a/packages/panel/src/tasks/agent-turn-foot.test.ts +++ b/packages/panel/src/tasks/agent-turn-foot.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -23,7 +19,7 @@ import { LinkWorkspaceProvider } from "../copy-link-item.js"; import { MemberDirectory } from "../member-directory.js"; import { fakeActPort, fakeAppBridge } from "../testing/app-bridge.js"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab } from "./agent-tab.js"; /** @@ -93,16 +89,10 @@ async function drawnTurn(status: CodingAgentStatus): Promise { Promise.resolve([{ ...DEAN, email: "dean@example.com", role: WORKSPACE_ROLE.OWNER }]), }); const messages = [TASK, turn(status !== CODING_AGENT_STATUS.RUNNING)]; - // Note that only the first read answers, because a read past the cursor waits on the service, and one answered at once would be read again without end. - let reads = 0; const control = codingAgentsControl({ agents: [agentOf(status)], me: DEAN.userId, - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ messages, cursor: "2:0", status, title: null }); - }, + transcripts: pushedTranscripts(() => [{ messages, cursor: "2:0", status, title: null }]), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/tasks/coding-agent-model.test.ts b/packages/panel/src/tasks/coding-agent-model.test.ts index a050ede406..48c74cbe04 100644 --- a/packages/panel/src/tasks/coding-agent-model.test.ts +++ b/packages/panel/src/tasks/coding-agent-model.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; import { CHECK_SUMMARY, - CODING_AGENT_STATUS, CONTEXT_WINDOW, type CodingAgentMessage, type CodingAgentPullRequest, @@ -26,7 +25,6 @@ import { choiceOn, delegates, effortFor, - followsAgent, knownDeveloperRows, messageFailureNote, messageFailureWords, @@ -44,19 +42,6 @@ import { unreadSentLines, } from "./coding-agent-model.js"; -test("the transcript is followed only while the tab shows and the agent may still write", () => { - assert.equal(followsAgent({ shown: true, status: CODING_AGENT_STATUS.RUNNING }), true); - assert.equal(followsAgent({ shown: true, status: CODING_AGENT_STATUS.STARTING }), true); - assert.equal(followsAgent({ shown: false, status: CODING_AGENT_STATUS.RUNNING }), false); - for (const ended of [ - CODING_AGENT_STATUS.COMPLETED, - CODING_AGENT_STATUS.FAILED, - CODING_AGENT_STATUS.CANCELLED, - ]) { - assert.equal(followsAgent({ shown: true, status: ended }), false, ended); - } -}); - test("a model is named by the catalog where it has been read, else by its own id made readable, either way without Claude before its name", () => { const models = [ { diff --git a/packages/panel/src/tasks/coding-agent-model.ts b/packages/panel/src/tasks/coding-agent-model.ts index 52962de0b0..8ccfb81642 100644 --- a/packages/panel/src/tasks/coding-agent-model.ts +++ b/packages/panel/src/tasks/coding-agent-model.ts @@ -329,7 +329,7 @@ export function endLineReason( : FAILURE_REASON.OTHER; } -/** The statuses an agent may still write under, which is when its transcript is followed and it can be stopped. */ +/** The statuses an agent may still write under, which is when it can be stopped. */ const FOLLOWED_STATUSES: ReadonlySet = new Set([ CODING_AGENT_STATUS.STARTING, CODING_AGENT_STATUS.RUNNING, @@ -355,15 +355,6 @@ export function agentStateWords(status: CodingAgentStatus): string | undefined { return glyphStateWords(agentGlyphState(status), status === CODING_AGENT_STATUS.CANCELLED); } -/** - * Whether an agent's transcript is read on the held long-poll now: only - * while its tab is on screen and the agent may still write. A tab hidden, - * a task left, and an agent that ended each stop the reads. - */ -export function followsAgent(input: { shown: boolean; status: CodingAgentStatus }): boolean { - return input.shown && agentStillWriting(input.status); -} - /** What the Start button says while it cannot start: the task names no repository yet. */ export const START_NEEDS_REPOSITORY = "Choose a repository first."; diff --git a/packages/panel/src/tasks/continue-agent.test.ts b/packages/panel/src/tasks/continue-agent.test.ts index 13035921fd..ed06beeae1 100644 --- a/packages/panel/src/tasks/continue-agent.test.ts +++ b/packages/panel/src/tasks/continue-agent.test.ts @@ -4,7 +4,6 @@ import assert from "node:assert/strict"; import { CODING_AGENT_CALL_FAILURE, type CodingAgentAgentAnswer, - type CodingAgentMessagesAnswerView, } from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, @@ -30,7 +29,7 @@ import { fakeAppBridge } from "../testing/app-bridge.js"; import { composerEditor, editorBundleLanded, installEditorLayout } from "../testing/composer.js"; import { onRegistry } from "../testing/copy-feedback.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab } from "./agent-tab.js"; import { continuationsOf, @@ -193,7 +192,6 @@ async function drawnTab(input: { continued.push({ agentId, note, attachments }); return input.answer?.() ?? Promise.resolve(undefined); }; - let reads = 0; const control = codingAgentsControl({ agents: [source, ...(input.others ?? [])], archived: input.archived ?? [], @@ -209,18 +207,16 @@ async function drawnTab(input: { }, onMessage: () => Promise.resolve(input.message ?? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [...(input.messages ?? MESSAGES)], cursor: "2:0", status: input.status, title: null, ...(input.failure === undefined ? undefined : { failureReason: input.failure }), ...input.usage, - }); - }, + }, + ]), }); const container = document.body.appendChild(document.createElement("div")); const root = createRoot(container); diff --git a/packages/panel/src/tasks/fork-action.test.ts b/packages/panel/src/tasks/fork-action.test.ts index db00c4975a..78a67e8876 100644 --- a/packages/panel/src/tasks/fork-action.test.ts +++ b/packages/panel/src/tasks/fork-action.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -18,7 +14,7 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; import { fakeAppBridge } from "../testing/app-bridge.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AGENT_LEAD, AgentMessage, AgentTab } from "./agent-tab.js"; import { agentForkDoors, ForkProvider } from "./fork-action.js"; import type { AgentFork } from "./fork-model.js"; @@ -227,15 +223,11 @@ async function forkedAtInTab( act: () => Promise.resolve({ status: ACT_OUTCOME_STATUS.REFUSED, reason: "Not here." }), }); const agent = summary(SOURCE, { status, brief: CODING_AGENT_BRIEF.PROMPT }); - // Note that only the first read answers, because a read past the cursor waits on the service. - let reads = 0; const control = codingAgentsControl({ agents: [agent], - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ messages: [...messages], cursor: "2:0", status, title: null }); - }, + transcripts: pushedTranscripts(() => [ + { messages: [...messages], cursor: "2:0", status, title: null }, + ]), }); const forks: AgentFork[] = []; const doors = agentForkDoors({ diff --git a/packages/panel/src/tasks/message-links.test.ts b/packages/panel/src/tasks/message-links.test.ts index 57f0068b80..823d4b3d02 100644 --- a/packages/panel/src/tasks/message-links.test.ts +++ b/packages/panel/src/tasks/message-links.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -22,7 +18,7 @@ import { LinkWorkspaceProvider } from "../copy-link-item.js"; import { fakeAppBridge } from "../testing/app-bridge.js"; import { copyingInto, fakeAppPlatform, QUIET_PANEL } from "../testing/app-platform.js"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { newestToastSays } from "../testing/toast.js"; import { ToastHost } from "../toast.js"; import { AgentTab } from "./agent-tab.js"; @@ -94,25 +90,19 @@ async function drawn( }> { const revealed: number[] = []; const registry = AtomRegistry.make(); - // Note that the transcript ends on a page with nothing on it, as an ended agent's does, and nothing is read past it. - let reads = 0; + // Note that the transcript ends on a page with nothing on it, as an ended agent's does. const control = codingAgentsControl({ agents: [agent], - readTranscript: (): Promise => { - reads += 1; - if (reads > pages.length + 1) { - return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - } - const page = { - messages: [...(pages[reads - 1] ?? [])], - cursor: `${reads}:0`, - status: agent.status, - title: null, - }; - return held !== undefined && held.page === reads - 1 - ? held.release.then(() => page) - : Promise.resolve(page); - }, + transcripts: pushedTranscripts( + () => + [...pages, []].map((messages, at) => ({ + messages: [...messages], + cursor: `${at + 1}:0`, + status: agent.status, + title: null, + })), + held, + ), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/tasks/replay-card.test.ts b/packages/panel/src/tasks/replay-card.test.ts index 6e9835dd38..787e6fb1d0 100644 --- a/packages/panel/src/tasks/replay-card.test.ts +++ b/packages/panel/src/tasks/replay-card.test.ts @@ -7,10 +7,6 @@ import { REPLAY_STEP_KIND, type Replay, } from "@luke/features/agent-replay"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -27,7 +23,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, test } from "vitest"; import { onRegistry } from "../testing/copy-feedback.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab, type TranscriptCards } from "./agent-tab.js"; import { TOOL_TARGET, type ToolTarget } from "./tool-call-model.js"; import { OpenLinkProvider } from "./use-open-link.js"; @@ -128,24 +124,20 @@ async function drawn( }, { id: "turn", role: MESSAGE_ROLE.ASSISTANT, parts: [{ type: "text", text: "On it." }] }, ]; - // Note that only the first read answers, because a read past the cursor waits on the service. - let reads = 0; const control = codingAgentsControl({ agents: [agent], // An agent's own page lists that agent alone, and reads any other by its id. archived: listed ? [source] : [], readAgent: (agentId) => Promise.resolve(agentId === source.id ? source : undefined), me: DEAN.userId, - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [...messages], cursor: "2:0", status: CODING_AGENT_STATUS.COMPLETED, title: null, - }); - }, + }, + ]), readPullRequest: () => Promise.resolve({ repository: "acme/app", diff --git a/packages/panel/src/tasks/reply-to-selection.test.ts b/packages/panel/src/tasks/reply-to-selection.test.ts index 9e83297c48..f514d599fc 100644 --- a/packages/panel/src/tasks/reply-to-selection.test.ts +++ b/packages/panel/src/tasks/reply-to-selection.test.ts @@ -31,7 +31,7 @@ import { } from "../testing/composer.js"; import { onRegistry } from "../testing/copy-feedback.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentComposer } from "./agent-composer.js"; import { AGENT_LEAD, AgentMessage, AgentTab } from "./agent-tab.js"; import { ReplyToSelection } from "./reply-to-selection.js"; @@ -390,29 +390,21 @@ const AGENT: CodingAgentSummary = { test("on the agent's tab, Reply quotes its words into the tab's own box, and stands down while a message is out", async () => { const sent: string[] = []; - let reads = 0; const control = codingAgentsControl({ agents: [AGENT], // The transcript ends on a page with nothing on it, as an ended agent's does. - readTranscript: () => { - reads += 1; - return Promise.resolve({ - messages: - reads === 1 - ? [ - { - id: "task", - role: MESSAGE_ROLE.USER, - parts: [{ type: "text", text: "Plan it." }], - }, - TURN, - ] - : [], - cursor: `${String(reads)}:0`, + transcripts: pushedTranscripts(() => [ + { + messages: [ + { id: "task", role: MESSAGE_ROLE.USER, parts: [{ type: "text", text: "Plan it." }] }, + TURN, + ], + cursor: "1:0", status: AGENT.status, title: null, - }); - }, + }, + { messages: [], cursor: "2:0", status: AGENT.status, title: null }, + ]), // The message never answers, so it stays out. onMessage: (_agentId, text) => { sent.push(text); diff --git a/packages/panel/src/tasks/server-card.test.ts b/packages/panel/src/tasks/server-card.test.ts index 5e276b92fd..b0af8b8443 100644 --- a/packages/panel/src/tasks/server-card.test.ts +++ b/packages/panel/src/tasks/server-card.test.ts @@ -1,11 +1,7 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentAgentAnswer, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; +import type { CodingAgentAgentAnswer } from "@luke/features/coding-agent-view"; import { CODING_AGENT_BRIEF, CODING_AGENT_STATUS, @@ -39,7 +35,7 @@ import { import { onRegistry } from "../testing/copy-feedback.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentTab } from "./agent-tab.js"; import { TaskMentions, type TaskMentionsValue } from "./task-mentions.js"; import { TranscriptWords } from "./transcript-blocks.js"; @@ -221,20 +217,16 @@ function agentTab( messages: readonly CodingAgentMessage[], agent: CodingAgentSummary, ): Promise { - // Note that only the first read answers, because a read past the cursor waits on the service. - let reads = 0; const control = codingAgentsControl({ agents: [agent], - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [...messages], cursor: "9:0", status: agent.status, title: null, - }); - }, + }, + ]), onMessage: () => new Promise(ignore), }); return drawn( diff --git a/packages/panel/src/tasks/subagent-tab.test.ts b/packages/panel/src/tasks/subagent-tab.test.ts index e4ebababdc..7b91ddfb1a 100644 --- a/packages/panel/src/tasks/subagent-tab.test.ts +++ b/packages/panel/src/tasks/subagent-tab.test.ts @@ -1,10 +1,6 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentStatus, @@ -16,7 +12,7 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentSubagentTab } from "./agent-tab.js"; /** @@ -62,14 +58,10 @@ async function workerTab( parts: readonly WireRecord[], opened: { task: string; first?: string; later?: string } = { task: "Audit the CSV writers" }, ): Promise { - // Note that only the first read answers, because a read past the cursor waits on the service, and one answered at once would be read again without end. - let reads = 0; const control = codingAgentsControl({ agents: [AGENT], - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [ ...(opened.first === undefined ? [] @@ -94,8 +86,8 @@ async function workerTab( cursor: "1:0", status, title: null, - }); - }, + }, + ]), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/tasks/use-agent-transcript.test.ts b/packages/panel/src/tasks/use-agent-transcript.test.ts new file mode 100644 index 0000000000..1a61499515 --- /dev/null +++ b/packages/panel/src/tasks/use-agent-transcript.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import assert from "node:assert/strict"; +import { + CODING_AGENT_CALL_FAILURE, + type CodingAgentMessagesAnswerView, + type CodingAgentTranscriptChanged, + type CodingAgentTranscriptKey, + TRANSCRIPT_KIND, +} from "@luke/features/coding-agent-view"; +import { CODING_AGENT_STATUS } from "@luke/features/coding-agent-wire"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, test } from "vitest"; +import { + type AgentTranscriptControl, + type TranscriptFollower, + useAgentTranscript, +} from "./use-agent-transcript.js"; + +/** + * use-agent-transcript.test.ts -- a tab's transcript is followed while the + * tab is shown and let go when it is not; the pages pushed for it join by + * id, a page pushed for another transcript is left alone, and one that says + * the transcript could not be read offers Try again, which follows it anew + * while the messages held stand. Synthetic ids throughout. + */ + +const AGENT: CodingAgentTranscriptKey = { + kind: TRANSCRIPT_KIND.AGENT, + agentId: "8c1a6a4f-3d2e-4d8b-8b66-6f4c7a2e3b21", +}; +const OTHER: CodingAgentTranscriptKey = { ...AGENT, callId: "call-7" }; + +let root: Root | undefined; + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + document.body.innerHTML = ""; +}); + +/** A follower that writes down each follow and let-go, and pushes what the test says. */ +function scriptedFollower() { + const asked: string[] = []; + const listeners = new Set<(changed: CodingAgentTranscriptChanged) => void>(); + const follower: TranscriptFollower = { + follow: (transcript) => void asked.push(`follow:${transcript.callId ?? "own"}`), + unfollow: (transcript) => void asked.push(`unfollow:${transcript.callId ?? "own"}`), + onTranscript: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const push = (transcript: CodingAgentTranscriptKey, page: CodingAgentMessagesAnswerView) => + act(() => { + for (const listener of listeners) listener({ transcript, page }); + }); + return { follower, asked, push }; +} + +/** A page saying `text` under `id`. */ +function pageOf(id: string, text: string): CodingAgentMessagesAnswerView { + return { + messages: [{ id, role: "assistant", parts: [{ type: "text", text }] }], + cursor: "1:0", + status: CODING_AGENT_STATUS.RUNNING, + title: null, + }; +} + +/** What the probe last drew of the hook. */ +interface Seen { + current: AgentTranscriptControl | undefined; +} + +/** The hook drawn under a probe that keeps what it last answered. */ +function drawn(follower: TranscriptFollower) { + const seen: Seen = { current: undefined }; + function Probe(props: { shown: boolean }) { + seen.current = useAgentTranscript({ + transcript: AGENT, + shown: props.shown, + follower, + onStatus: () => undefined, + onTitle: () => undefined, + }); + return null; + } + const container = document.body.appendChild(document.createElement("div")); + root = createRoot(container); + const draw = (shown: boolean) => act(() => root?.render(createElement(Probe, { shown }))); + return { seen, draw }; +} + +test("a shown tab follows its transcript, joins the pages pushed for it alone, and lets it go once hidden", () => { + const { follower, asked, push } = scriptedFollower(); + const { seen, draw } = drawn(follower); + + draw(true); + push(AGENT, pageOf("m-1", "Reading the invites.")); + push(OTHER, pageOf("m-9", "A worker's words.")); + push(AGENT, pageOf("m-1", "Reading the invites again.")); + push(AGENT, pageOf("m-2", "Writing the expiry.")); + assert.deepEqual( + seen.current?.messages.map((message) => message.id), + ["m-1", "m-2"], + ); + assert.equal(seen.current?.pages, 3); + + draw(false); + push(AGENT, pageOf("m-3", "Not heard.")); + assert.deepEqual(asked, ["follow:own", "unfollow:own"]); + assert.equal(seen.current?.messages.length, 2); +}); + +test("a page that could not be read offers Try again, which follows the transcript anew, the messages held standing", () => { + const { follower, asked, push } = scriptedFollower(); + const { seen, draw } = drawn(follower); + + draw(true); + push(AGENT, pageOf("m-1", "Reading the invites.")); + push(AGENT, { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); + assert.equal(seen.current?.failed, true); + assert.equal(seen.current?.messages.length, 1); + + act(() => seen.current?.onRetry()); + push(AGENT, pageOf("m-2", "Writing the expiry.")); + assert.equal(seen.current?.failed, false); + assert.deepEqual(asked, ["follow:own", "unfollow:own", "follow:own"]); + assert.deepEqual( + seen.current?.messages.map((message) => message.id), + ["m-1", "m-2"], + ); +}); diff --git a/packages/panel/src/tasks/use-agent-transcript.ts b/packages/panel/src/tasks/use-agent-transcript.ts index 18447f18e9..529faa0b78 100644 --- a/packages/panel/src/tasks/use-agent-transcript.ts +++ b/packages/panel/src/tasks/use-agent-transcript.ts @@ -1,11 +1,12 @@ import { - CODING_AGENT_CALL_FAILURE, type CodingAgentMessagesAnswerView, type CodingAgentMessagesParams, + type CodingAgentTranscriptChanged, + type CodingAgentTranscriptKey, + TRANSCRIPT_KIND, } from "@luke/features/coding-agent-view"; import { applyMessagesPage, - CODING_AGENT_CURSOR_START, type CodingAgentContext, type CodingAgentFailure, type CodingAgentMessage, @@ -13,35 +14,63 @@ import { type CodingAgentSubagentStanding, type CodingAgentUsage, } from "@luke/features/coding-agent-wire"; +import { ACT_KIND } from "@luke/host/acts"; import { useCallback, useEffect, useRef, useState } from "react"; -import { type AgentRun, agentStillWriting, followsAgent } from "./coding-agent-model.js"; +import type { ActHandle } from "../act.js"; +import { appBridge } from "../app-bridge.js"; +import { type AgentRun, agentStillWriting } from "./coding-agent-model.js"; /** - * use-agent-transcript.ts -- one agent's transcript, or one of its workers', as its tab draws it, read page by page on the service's held long-poll while the agent may still write. + * use-agent-transcript.ts -- one agent's transcript, or one of its workers', or a task's planning worker's, as its tab draws it, pushed to the window page by page while the tab is on screen. * - * The service holds a read open while the agent is starting or runs and - * nothing new stands past the cursor, and lets it go at its own hold, so - * the loop here is one read after another with no clock of its own: each - * answer's cursor is where the next read starts, each page joins the - * messages held by id, and each page's status is the agent's as the page - * was read, with why where its turn failed, which the tab's end line says, - * what its model's context holds, which the box's ring draws, and when its - * run and each worker it waits on began, which the working line and a - * subagent's row count from. - * The loop runs while the tab is on screen and the agent may - * still write, and ends on its own when a page says the agent ended; an - * agent that has ended is read once through, page by page, and then left - * alone. A read that did not answer ends the loop and says so, with Try - * again, rather than asking again at once. A worker's transcript is read - * the same way under the agent and the delegation call that started it, - * and where the worker stands is what its pages say. + * The tab follows its transcript while it is shown and lets it go when it + * is not; main follows it at the host and sends every page to the windows + * following it on `app:transcript` (`main/ipc/transcript-follow-acts.ts`), + * so nothing here asks again, holds a cursor, or keeps a loop. The first + * page is the transcript from its start, each later one what moved past + * the last, and each joins the messages held by id. Each page's status is + * the agent's as the page was read, with why where its turn failed, which + * the tab's end line says, what its model's context holds, which the box's + * ring draws, and when its run and each worker it waits on began, which the + * working line and a subagent's row count from. A page that says the + * transcript could not be read says so, with Try again, which follows it + * anew; the messages held stand meanwhile. */ -/** How one read is asked: the agent, the cursor to read past, and the worker's call where it is a worker's, answered as the view hears it. */ +/** How one read is asked at once: the agent, the cursor to read past, and the worker's call where it is a worker's, answered as the view hears it. Copy transcript reads this way. */ export type TranscriptReader = ( params: CodingAgentMessagesParams, ) => Promise; +/** How the window follows a transcript, lets it go, and hears its pages pushed. */ +export interface TranscriptFollower { + readonly follow: (transcript: CodingAgentTranscriptKey) => void; + readonly unfollow: (transcript: CodingAgentTranscriptKey) => void; + /** Hears every transcript page pushed to the window; the returned call stops hearing. */ + readonly onTranscript: (listener: (changed: CodingAgentTranscriptChanged) => void) => () => void; +} + +/** The window's own follower: the follow acts out, and `app:transcript` in. */ +export function windowTranscriptFollower(acts: Pick): TranscriptFollower { + return { + follow: (transcript) => + void acts.act(ACT_KIND.CODING_AGENTS_TRANSCRIPT_FOLLOW, transcript).catch(() => undefined), + unfollow: (transcript) => + void acts.act(ACT_KIND.CODING_AGENTS_TRANSCRIPT_UNFOLLOW, transcript).catch(() => undefined), + onTranscript: (listener) => appBridge().onTranscript(listener), + }; +} + +/** The agent's or task's id a transcript is under. */ +function ownerOf(transcript: CodingAgentTranscriptKey): string { + return transcript.kind === TRANSCRIPT_KIND.AGENT ? transcript.agentId : transcript.taskId; +} + +/** Whether two keys name the same transcript. */ +function sameTranscript(one: CodingAgentTranscriptKey, other: CodingAgentTranscriptKey): boolean { + return one.kind === other.kind && ownerOf(one) === ownerOf(other) && one.callId === other.callId; +} + /** What the tab draws of the transcript. */ export interface AgentTranscriptControl { messages: readonly CodingAgentMessage[]; @@ -75,17 +104,16 @@ export interface AgentTranscriptControl { } interface Held { - agentId: string; + transcript: CodingAgentTranscriptKey; messages: readonly CodingAgentMessage[]; - cursor: string; failure: CodingAgentFailure | undefined; usage: CodingAgentUsage | undefined; context: CodingAgentContext | undefined; runStartedAt: number | undefined; pageStatus: CodingAgentStatus | undefined; - /** Whether the last page read came back with nothing more on it, so every message written by then is read. */ + /** Whether the last page pushed came with nothing more on it, so every message written by then is held. */ caughtUp: boolean; - /** How many pages have been read, which an ask counts from to know a page landed after it. */ + /** How many pages have been pushed, which an ask counts from to know a page landed after it. */ pages: number; pageTitle: string | undefined; subagentTitles: ReadonlyMap; @@ -94,11 +122,10 @@ interface Held { failed: boolean; } -function fresh(agentId: string): Held { +function fresh(transcript: CodingAgentTranscriptKey): Held { return { - agentId, + transcript, messages: [], - cursor: CODING_AGENT_CURSOR_START, failure: undefined, usage: undefined, context: undefined, @@ -114,112 +141,78 @@ function fresh(agentId: string): Held { }; } +/** A page pushed, joined to what is held: its messages by id, and where the agent stands as it says. */ +function joined(was: Held, page: CodingAgentMessagesAnswerView): Held { + if ("failure" in page) return { ...was, failed: true }; + return { + ...was, + messages: applyMessagesPage(was.messages, page.messages), + failure: page.failureReason, + usage: page.usage, + context: page.context, + runStartedAt: page.runStartedAt, + pageStatus: page.status, + caughtUp: page.messages.length === 0, + pages: was.pages + 1, + pageTitle: page.title ?? undefined, + subagentTitles: new Map( + (page.subagentTitles ?? []).map((titled) => [titled.callId, titled.title]), + ), + delegations: new Map( + (page.delegations ?? []).map((started) => [started.callId, started.startedAt]), + ), + subagents: new Map((page.subagents ?? []).map((standing) => [standing.callId, standing])), + failed: false, + }; +} + export function useAgentTranscript(input: { - agentId: string; - /** The delegation call whose worker's transcript this is, where it is a worker's rather than the agent's own. */ - callId?: string | undefined; - /** The agent's, or the worker's, status as the tab knows it, which the pages read keep current through `onStatus`. */ - status: CodingAgentStatus; + /** Which transcript: the agent's own, one of its workers' by the call that started it, or a task's planning worker's. */ + transcript: CodingAgentTranscriptKey; /** Whether the tab is on screen: the panel open on it, on the Tasks tab. */ shown: boolean; - read: TranscriptReader; + follower: TranscriptFollower; /** A page said where the agent stands now, and since when, which is newer than the list. */ onStatus: (agentId: string, run: AgentRun) => void; /** A page said what the agent is titled now, which the list may have read before the title landed. */ onTitle: (agentId: string, title: string | null) => void; }): AgentTranscriptControl { - const { agentId, callId, status, shown, read, onStatus, onTitle } = input; - const [held, setHeld] = useState(() => fresh(agentId)); - // Note that another agent's tab starts from nothing of this one's. - if (held.agentId !== agentId) setHeld(fresh(agentId)); + const { shown, follower, onStatus, onTitle } = input; + const [held, setHeld] = useState(() => fresh(input.transcript)); + // Note that another transcript's tab starts from nothing of this one's. + const changed = !sameTranscript(held.transcript, input.transcript); + if (changed) setHeld(fresh(input.transcript)); + // Note that the key is kept as it was while it names the same transcript, so a tab drawn again follows nothing anew. + const transcript = changed ? input.transcript : held.transcript; const [attempt, setAttempt] = useState(0); - const latest = useRef({ read, onStatus, onTitle }); - latest.current = { read, onStatus, onTitle }; - const cursor = useRef(CODING_AGENT_CURSOR_START); - if (held.agentId !== agentId) cursor.current = CODING_AGENT_CURSOR_START; - // Whether this agent has been read through once: an ended agent is read - // once, page by page, and a running one for as long as it writes. - const readThrough = useRef(undefined); - - const following = followsAgent({ shown, status }); - const once = shown && !following && readThrough.current !== agentId; - const active = (following || once) && !held.failed; + const latest = useRef({ follower, onStatus, onTitle }); + latest.current = { follower, onStatus, onTitle }; useEffect(() => { - if (!active) return; - let live = true; - const loop = async () => { - while (live) { - const answer = await latest.current - .read({ - agentId, - after: cursor.current, - ...(callId === undefined ? undefined : { callId }), - }) - .catch( - (): CodingAgentMessagesAnswerView => ({ - failure: CODING_AGENT_CALL_FAILURE.UNANSWERED, - }), - ); - if (!live) return; - if ("failure" in answer) { - setHeld((was) => ({ ...was, failed: true })); - return; - } - cursor.current = answer.cursor; - setHeld((was) => ({ - ...was, - messages: applyMessagesPage(was.messages, answer.messages), - cursor: answer.cursor, - failure: answer.failureReason, - usage: answer.usage, - context: answer.context, - runStartedAt: answer.runStartedAt, - pageStatus: answer.status, - caughtUp: answer.messages.length === 0, - pages: was.pages + 1, - pageTitle: answer.title ?? undefined, - subagentTitles: new Map( - (answer.subagentTitles ?? []).map((titled) => [titled.callId, titled.title]), - ), - delegations: new Map( - (answer.delegations ?? []).map((started) => [started.callId, started.startedAt]), - ), - subagents: new Map( - (answer.subagents ?? []).map((standing) => [standing.callId, standing]), - ), - })); - latest.current.onStatus(agentId, answer); - latest.current.onTitle(agentId, answer.title); - // A page with nothing on it from an agent that may still write is - // the hold letting go: ask again. One from an agent that ended is - // the end of the transcript, once no worker of its own session still - // reads as working, since a worker stopped with its agent may settle - // a look after it and its row would stand running. - const workerStillWriting = (answer.subagents ?? []).some((standing) => - agentStillWriting(standing.status), - ); - if ( - answer.messages.length === 0 && - !agentStillWriting(answer.status) && - !workerStillWriting - ) { - readThrough.current = agentId; - return; - } - } - }; - void loop(); + if (!shown) return; + const { follower: following } = latest.current; + const agentId = ownerOf(transcript); + const stop = following.onTranscript((pushed) => { + if (!sameTranscript(pushed.transcript, transcript)) return; + const { page } = pushed; + setHeld((was) => (sameTranscript(was.transcript, transcript) ? joined(was, page) : was)); + if ("failure" in page) return; + latest.current.onStatus(agentId, page); + latest.current.onTitle(agentId, page.title); + }); + following.follow(transcript); return () => { - live = false; + stop(); + following.unfollow(transcript); }; - }, [active, agentId, callId, attempt]); + }, [shown, transcript, attempt]); const onRetry = useCallback(() => { setHeld((was) => ({ ...was, failed: false })); setAttempt((count) => count + 1); }, []); + const writing = held.pageStatus === undefined || agentStillWriting(held.pageStatus); return { messages: held.messages, failure: held.failure, @@ -233,7 +226,7 @@ export function useAgentTranscript(input: { subagentTitles: held.subagentTitles, delegations: held.delegations, subagents: held.subagents, - reading: active && held.messages.length === 0, + reading: shown && !held.failed && held.messages.length === 0 && writing, failed: held.failed, onRetry, }; diff --git a/packages/panel/src/tasks/use-coding-agents.ts b/packages/panel/src/tasks/use-coding-agents.ts index 6b7cc3bbbd..14f4006569 100644 --- a/packages/panel/src/tasks/use-coding-agents.ts +++ b/packages/panel/src/tasks/use-coding-agents.ts @@ -63,7 +63,11 @@ import type { MessageSender } from "./use-agent-composer.js"; import type { ChoiceWriter } from "./use-agent-model.js"; import { type PullRequestReader, useTaskPullRequests } from "./use-agent-pull-request.js"; import type { ServersWriter } from "./use-agent-servers.js"; -import type { TranscriptReader } from "./use-agent-transcript.js"; +import { + type TranscriptFollower, + type TranscriptReader, + windowTranscriptFollower, +} from "./use-agent-transcript.js"; import type { InboxAnswerer, InboxCloser, @@ -292,8 +296,10 @@ export interface CodingAgentsControl { onStatus: (agentId: string, run: AgentRun) => void; /** An agent's transcript page said what it is titled now, which the list read before the title landed did not. */ onTitle: (agentId: string, title: string | null) => void; - /** One read of an agent's transcript past a cursor, as the tab's loop asks it. */ + /** One read of an agent's transcript past a cursor at once, as Copy transcript asks it. */ readTranscript: TranscriptReader; + /** How a shown tab follows its transcript and hears its pages pushed. */ + transcripts: TranscriptFollower; /** One read of what an agent published, as its tab asks it, its answer kept in `pullRequests`. */ readPullRequest: PullRequestReader; /** Each of the task's agents' pull requests, by agent, of those that have one (`useTaskPullRequests`). */ @@ -1131,6 +1137,7 @@ export function useCodingAgents(input: { (params) => act(ACT_KIND.CODING_AGENTS_MESSAGES, params), [act], ); + const transcripts = useMemo(() => windowTranscriptFollower({ act }), [act]); const readPullRequestOnce = useCallback( (agentId) => act(ACT_KIND.CODING_AGENTS_PULL_REQUEST, { agentId }), @@ -1326,6 +1333,7 @@ export function useCodingAgents(input: { onStatus, onTitle, readTranscript, + transcripts, readPullRequest: published.read, pullRequests: published.pullRequests, numberedPullRequests, diff --git a/packages/panel/src/tasks/worker-mac.test.ts b/packages/panel/src/tasks/worker-mac.test.ts index 4eb4dc7913..2c22f6c7ec 100644 --- a/packages/panel/src/tasks/worker-mac.test.ts +++ b/packages/panel/src/tasks/worker-mac.test.ts @@ -1,17 +1,13 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentMessagesAnswerView, -} from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentSummary } from "@luke/features/coding-agent-wire"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; import { onRegistry } from "../testing/copy-feedback.js"; -import { codingAgentsControl } from "../testing/tasks-control.js"; +import { codingAgentsControl, pushedTranscripts } from "../testing/tasks-control.js"; import { AgentSubagentTab } from "./agent-tab.js"; /** @@ -46,15 +42,11 @@ afterEach(() => { /** Where the worker's tab says its Mac command ran once its page is read, as `reader` sees it. */ async function workerTabFor(reader: string): Promise { - // Note that only the first read answers, because a read past the cursor waits on the service, and one answered at once would be read again without end. - let reads = 0; const control = codingAgentsControl({ agents: [AGENT], me: reader, - readTranscript: (): Promise => { - reads += 1; - if (reads > 1) return Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - return Promise.resolve({ + transcripts: pushedTranscripts(() => [ + { messages: [ { id: "worker-step", @@ -73,8 +65,8 @@ async function workerTabFor(reader: string): Promise { cursor: "1:0", status: CODING_AGENT_STATUS.COMPLETED, title: null, - }); - }, + }, + ]), }); const container = document.body.appendChild(document.createElement("div")); root = createRoot(container); diff --git a/packages/panel/src/testing/tasks-control.ts b/packages/panel/src/testing/tasks-control.ts index 690908183f..023a331ee9 100644 --- a/packages/panel/src/testing/tasks-control.ts +++ b/packages/panel/src/testing/tasks-control.ts @@ -1,4 +1,9 @@ -import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; +import { + CODING_AGENT_CALL_FAILURE, + type CodingAgentMessagesAnswerView, + type CodingAgentTranscriptChanged, + type CodingAgentTranscriptKey, +} from "@luke/features/coding-agent-view"; import type { InboxItem } from "@luke/features/inbox-wire"; import { TASK_CALL_FAILURE } from "@luke/features/tasks-view"; import type { AppInboxWaitingItem } from "@luke/host/app-state"; @@ -9,6 +14,7 @@ import { DOCUMENT_REGION, TASKS_PAGE } from "@luke/ui/tasks-model"; import { TRANSCRIPT_REGION } from "@luke/voice/transcript-model"; import { ACTION_RESULT_STATUS } from "@luke/wire"; import type { SummaryPin } from "../desktop/task-summary.js"; +import type { TranscriptFollower } from "../tasks/use-agent-transcript.js"; import type { CodingAgentsControl } from "../tasks/use-coding-agents.js"; import { MAIN_TAB } from "../tasks/use-main-tabs.js"; import { HISTORY_READ } from "../tasks/use-plan-history.js"; @@ -23,6 +29,40 @@ import { const ignore = () => undefined; /** An open item as main's read of the account carries it into what waits: its id, its agent, what it asks, and when it was posted. */ +/** + * A follower that answers each follow as the host would: the pages `pagesOf` + * names for the transcript, pushed to the window in turn, at once; or, where + * `held` names one of them, that page and those after it once its release + * lands. + */ +export function pushedTranscripts( + pagesOf: (transcript: CodingAgentTranscriptKey) => readonly CodingAgentMessagesAnswerView[], + held?: { readonly page: number; readonly release: Promise }, +): TranscriptFollower { + const listeners = new Set<(changed: CodingAgentTranscriptChanged) => void>(); + const push = ( + transcript: CodingAgentTranscriptKey, + pages: readonly CodingAgentMessagesAnswerView[], + ) => { + for (const page of pages) { + for (const listener of listeners) listener({ transcript, page }); + } + }; + return { + follow: (transcript) => { + const pages = pagesOf(transcript); + const cut = held?.page ?? pages.length; + push(transcript, pages.slice(0, cut)); + void held?.release.then(() => push(transcript, pages.slice(cut))); + }, + unfollow: () => undefined, + onTranscript: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + export function waitingOf(item: InboxItem): AppInboxWaitingItem { return { id: item.id, agentId: item.agentId, title: item.title, postedAt: item.postedAt }; } @@ -124,6 +164,7 @@ export function codingAgentsControl( onStatus: ignore, onTitle: ignore, readTranscript: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), + transcripts: pushedTranscripts(() => []), readPullRequest: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), pullRequests: new Map(), numberedPullRequests: new Map(), From aa24df6a6b1693e1aa0bf1f02193dcf90e7b82a1 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:12:39 +0000 Subject: [PATCH 2/2] fix(panel): a subagent's tab content takes no work it no longer reads Following a planning worker's transcript leaves the side panel's tab content reading nothing of the task's work, so the prop it no longer used is dropped, which main's oxlint refuses as an unused parameter. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/panel/src/desktop/side-panel.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/panel/src/desktop/side-panel.tsx b/packages/panel/src/desktop/side-panel.tsx index d345647ba8..221f18c80f 100644 --- a/packages/panel/src/desktop/side-panel.tsx +++ b/packages/panel/src/desktop/side-panel.tsx @@ -1341,7 +1341,6 @@ function TabContent({ files, pages, transcript, - work, workRows, agents, standings, @@ -1380,7 +1379,6 @@ function TabContent({ /** The plan's pages, which a page's tab draws its newest version of. */ pages: readonly PageLine[]; transcript: SidePanelTranscript; - work: SidePanelWork; /** The task's work as rows, which a subagent's tab reads its session from. */ workRows: readonly WorkTurnRow[]; agents: CodingAgentsControl; @@ -2112,7 +2110,6 @@ export function SidePanel({ files={files} pages={taskPages} transcript={transcript} - work={work} workRows={workRows} agents={agents} standings={standings}