From 0969d75bb72f8a0f584f45c42df866b284fb1efa Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:10:07 +0000 Subject: [PATCH] feat(panel,host): the windows' inbox reads become pushed follows, and the held inbox reads are retired The Inbox tab, the Inbox page, Home and Factory follow each plan's or agent's inbox they draw on the act port's `codingAgents.inboxFollow` stream (`use-followed-inboxes.ts`), and hold its items as each change arrives: no read loop, no cursor, no reread after marking every item read, since each push carries the reader's markers. A plan counts as current once its push is as new as main's word of it, so a link to a brand-new item waits rather than saying it is gone. The held reads `codingAgents.inbox` and `codingAgents.agentInbox` are retired: the act port's kinds and procedures, the Gateway methods, the host's calls, and their parameter schemas. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/features/src/coding-agent-view.ts | 16 - packages/gateway/src/protocol.ts | 3 - .../act-port/agent-inbox-procedures.test.ts | 47 +-- .../src/act-port/agent-inbox-procedures.ts | 34 +- .../host/src/compose-coding-agents.test.ts | 180 +---------- packages/host/src/compose-coding-agents.ts | 62 +--- packages/host/src/contract/agent-inbox.ts | 15 +- packages/host/src/contract/kinds.ts | 2 - packages/host/src/host-operator.ts | 26 -- packages/host/src/scope-inbox-follow.ts | 58 +--- packages/panel/src/composer-drafts.test.ts | 24 +- .../panel/src/desktop/desktop-factory.test.ts | 72 ++--- .../panel/src/desktop/desktop-home.test.ts | 21 +- .../panel/src/desktop/desktop-inbox.test.ts | 303 +++++++----------- .../panel/src/desktop/desktop-tasks.test.ts | 31 +- packages/panel/src/desktop/desktop-tasks.tsx | 1 - .../src/desktop/inbox-link-chips.test.ts | 33 +- .../src/desktop/inbox-side-panel.test.ts | 23 +- .../panel/src/desktop/person-page.test.ts | 23 +- packages/panel/src/read-failure.test.ts | 12 +- packages/panel/src/tasks/use-coding-agents.ts | 19 +- .../panel/src/tasks/use-followed-inboxes.ts | 137 ++++++++ .../panel/src/tasks/use-inbox-items.test.ts | 154 +++++---- packages/panel/src/tasks/use-inbox-items.ts | 163 +++------- packages/panel/src/tasks/use-inbox-listing.ts | 35 +- .../src/tasks/use-inbox-read-marks.test.ts | 10 +- .../panel/src/tasks/use-inbox-read-marks.ts | 14 +- packages/panel/src/tasks/use-task-inbox.ts | 95 +++--- .../src/tasks/use-taskless-inbox-items.ts | 158 ++------- packages/panel/src/tasks/use-waiting-items.ts | 20 +- .../panel/src/testing/followed-inboxes.ts | 81 +++++ packages/panel/src/testing/tasks-control.ts | 2 +- 32 files changed, 727 insertions(+), 1147 deletions(-) create mode 100644 packages/panel/src/tasks/use-followed-inboxes.ts create mode 100644 packages/panel/src/testing/followed-inboxes.ts diff --git a/packages/features/src/coding-agent-view.ts b/packages/features/src/coding-agent-view.ts index 29a7323c15..cd08bc8eb2 100644 --- a/packages/features/src/coding-agent-view.ts +++ b/packages/features/src/coding-agent-view.ts @@ -540,22 +540,6 @@ export const dictationTranscribeAnswerViewSchema = EffectSchema.Union([ export type DictationTranscribeAnswerView = typeof dictationTranscribeAnswerViewSchema.Type; -/** One task's inbox past a cursor, as the window asks it: the task, and where the window stands. */ -export const codingAgentInboxParamsSchema = EffectSchema.Struct({ - taskId: EffectSchema.NonEmptyString, - after: EffectSchema.NonEmptyString, -}); - -export type CodingAgentInboxParams = typeof codingAgentInboxParamsSchema.Type; - -/** One agent's inbox past a cursor, as the window asks it: the agent, and where the window stands; it is heard as a task's inbox is. */ -export const codingAgentAgentInboxParamsSchema = EffectSchema.Struct({ - agentId: EffectSchema.NonEmptyString, - after: EffectSchema.NonEmptyString, -}); - -export type CodingAgentAgentInboxParams = typeof codingAgentAgentInboxParamsSchema.Type; - /** Whose inbox is followed: a plan's, or one agent's. */ export const INBOX_SCOPE_KIND = { PLAN: "plan", diff --git a/packages/gateway/src/protocol.ts b/packages/gateway/src/protocol.ts index 85f1eda3d0..c3cff64c61 100644 --- a/packages/gateway/src/protocol.ts +++ b/packages/gateway/src/protocol.ts @@ -287,9 +287,6 @@ export const GATEWAY_METHOD = { PLAN_TEMPLATE_RESTORE: "planTemplate.restore", /** One clip dictated into an agent's message box, carried once to the service; its words, or why there are none. Nothing of it is kept. */ DICTATION_TRANSCRIBE: "dictation.transcribe", - /** One task's inbox past a cursor: every item its coding agents posted for the developer, held open by the service while nothing changed. */ - CODING_AGENTS_INBOX: "codingAgents.inbox", - CODING_AGENTS_AGENT_INBOX: "codingAgents.agentInbox", /** * One plan's inbox, or one agent's, followed from now until it is let go * or the account signs out, over Neon Realtime. What it holds is heard as diff --git a/packages/host/src/act-port/agent-inbox-procedures.test.ts b/packages/host/src/act-port/agent-inbox-procedures.test.ts index 00f8eed73b..ef508a5324 100644 --- a/packages/host/src/act-port/agent-inbox-procedures.test.ts +++ b/packages/host/src/act-port/agent-inbox-procedures.test.ts @@ -84,9 +84,6 @@ const UNANSWERED = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; /** Each kind's refusal, as the window drew it before the kinds moved onto the port. */ const REFUSAL = { - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX]: "Could not read the task's inbox on this system.", - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_AGENT_INBOX]: - "Could not read the agent's inbox on this system.", [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_ANSWER]: "Could not answer the inbox item on this system.", [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_CLOSE]: @@ -114,16 +111,6 @@ const ACTS: readonly { payload: WireBoundaryInput; ask: keyof HostAsk; }[] = [ - { - kind: AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX, - payload: { taskId: TASK_ID, after: "0" }, - ask: "codingAgentInbox", - }, - { - kind: AGENT_INBOX_ACT_KIND.CODING_AGENTS_AGENT_INBOX, - payload: { agentId: AGENT_ID, after: "0" }, - ask: "codingAgentAgentInbox", - }, { kind: AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_ANSWER, payload: { itemId: ITEM.id, revision: 2, reply: INBOX_REPLY.APPROVED, clientKey: "answer-1" }, @@ -242,10 +229,9 @@ it("a panel's ask reaches the host once with its payload, and the host's answer ); }); -it("a panel reads a task's inbox, answers, closes, and messages on an item, and hears the item as it then stands", async () => { +it("a panel answers, closes, and messages on an item, and hears the item as it then stands", async () => { const { call, asked } = windowPort(PANEL, { answers: { - codingAgentInbox: () => Effect.succeed({ items: [ITEM], cursor: "7" }), codingAgentInboxAnswer: () => Effect.succeed({ item: { ...ITEM, status: INBOX_STATUS.ANSWERED }, taken: true }), codingAgentInboxClose: () => @@ -262,10 +248,6 @@ it("a panel reads a task's inbox, answers, closes, and messages on an item, and const answer = { itemId: ITEM.id, revision: 2, reply: INBOX_REPLY.APPROVED, clientKey: "a-1" }; const close = { itemId: ITEM.id, revision: 2, clientKey: "close-1" }; - assert.deepEqual( - await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: "0" }), - { answer: { items: [ITEM], cursor: "7" } }, - ); assert.deepEqual(await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_ANSWER, answer), { answer: { item: { ...ITEM, status: INBOX_STATUS.ANSWERED }, taken: true }, }); @@ -276,7 +258,6 @@ it("a panel reads a task's inbox, answers, closes, and messages on an item, and answer: { item: ITEM }, }); assert.deepEqual(asked, [ - ["codingAgentInbox", { taskId: TASK_ID, after: "0" }], ["codingAgentInboxAnswer", answer], ["codingAgentInboxClose", close], ["codingAgentInboxMessage", message], @@ -410,8 +391,8 @@ it("an answer's key its kind's view schema does not name is left out of what the const { call } = windowPort(PANEL, { answers: { codingAgentInboxReadAll: () => Effect.succeed({ marked: 3, stray: true }), - codingAgentInbox: () => - Effect.succeed({ items: [{ ...ITEM, stray: true }], cursor: "7", stray: true }), + codingAgentInboxMessage: () => + Effect.succeed({ item: { ...ITEM, stray: true }, stray: true }), }, }); @@ -419,8 +400,12 @@ it("an answer's key its kind's view schema does not name is left out of what the answer: { marked: 3 }, }); assert.deepEqual( - await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: "0" }), - { answer: { items: [ITEM], cursor: "7" } }, + await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_MESSAGE, { + itemId: ITEM.id, + text: "Also page on-call.", + clientKey: "message-2", + }), + { answer: { item: ITEM } }, ); }); @@ -443,10 +428,6 @@ it("a payload carrying a key its kind's schema does not name is refused in the k it("a payload its kind's schema refuses, or none where the kind takes one, is refused in the kind's own words, and the host is not asked", async () => { const { call, asked } = windowPort(PANEL); - assert.deepEqual( - await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX, { taskId: "", after: "0" }), - { refused: REFUSAL[AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX] }, - ); assert.deepEqual( await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_READ_STATE, { itemId: ITEM.id, @@ -467,12 +448,16 @@ it("an item carrying a block and a response type this build does not know reache response: { type: "signature" }, }; const { call } = windowPort(PANEL, { - answers: { codingAgentInbox: () => Effect.succeed({ items: [newer], cursor: "7" }) }, + answers: { codingAgentInboxMessage: () => Effect.succeed({ item: newer }) }, }); assert.deepEqual( - await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: "0" }), - { answer: { items: [newer], cursor: "7" } }, + await call(AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_MESSAGE, { + itemId: ITEM.id, + text: "Ship it.", + clientKey: "message-3", + }), + { answer: { item: newer } }, ); }); diff --git a/packages/host/src/act-port/agent-inbox-procedures.ts b/packages/host/src/act-port/agent-inbox-procedures.ts index 977b7ad0cd..83d656e530 100644 --- a/packages/host/src/act-port/agent-inbox-procedures.ts +++ b/packages/host/src/act-port/agent-inbox-procedures.ts @@ -4,18 +4,16 @@ * The one check these procedures make is who asked, as for every task act: * only a panel draws the Inbox and the chat, so every one refuses the * takeover and the hidden voice window, in its own sentence. What the - * service answers comes back as the panel's own answer. The task's inbox is - * answered to the panel alone: the notices (`@luke/host/agent-notices`) - * watch what waits across the account on a read of their own. A - * workspace's chat is followed while a panel holds its stream open - * (`CODING_AGENTS_CHAT_FOLLOW`): the host follows it on the first stream of - * it and lets it go with the last, and every change it tells reaches every - * stream of it. How many streams stand open of each chat is all else held - * here. + * service answers comes back as the panel's own answer. A task's or an + * agent's inbox, and a workspace's chat, are each followed while a panel + * holds its stream open: the host follows one on the first stream of it and + * lets it go with the last, and every change it tells reaches every stream + * of it. How many streams stand open of each is all else held here; the + * notices (`@luke/host/agent-notices`) watch what waits across the account + * on a read of their own. */ import type { - CodingAgentAgentInboxParams, CodingAgentChatAnswerView, CodingAgentChatAttachmentParams, CodingAgentChatChanged, @@ -23,14 +21,12 @@ import type { CodingAgentChatWriteAnswerView, CodingAgentInboxAnsweredView, CodingAgentInboxAnswerParams, - CodingAgentInboxAnswerView, CodingAgentInboxAttachmentParams, CodingAgentInboxAttachmentView, CodingAgentInboxChanged, CodingAgentInboxCloseParams, CodingAgentInboxMessagedView, CodingAgentInboxMessageParams, - CodingAgentInboxParams, CodingAgentInboxReadAllView, CodingAgentInboxReadStateParams, CodingAgentInboxReadStateView, @@ -46,10 +42,6 @@ import { followedStream, type OpenFollows } from "./followed-stream.js"; /** What the Inbox and chat procedures reach: the host's asks of the service. */ export interface AgentInboxProceduresDependencies { host: { - codingAgentInbox(params: CodingAgentInboxParams): Effect.Effect; - codingAgentAgentInbox( - params: CodingAgentAgentInboxParams, - ): Effect.Effect; /** A task's or agent's inbox followed at the host, heard through `onInboxChanged`; following again starts over. */ codingAgentInboxFollow(scope: CodingAgentInboxScope): Effect.Effect; codingAgentInboxUnfollow(scope: CodingAgentInboxScope): Effect.Effect; @@ -189,18 +181,6 @@ export function agentInboxProcedures(dependencies: AgentInboxProceduresDependenc return made; }; return { - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX]: actPort[ - AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX - ].effect(function* ({ input, context, errors }) { - yield* refuseUnlessPanel(context.actor, () => errors.REFUSED()); - return yield* host.codingAgentInbox(input); - }), - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_AGENT_INBOX]: actPort[ - AGENT_INBOX_ACT_KIND.CODING_AGENTS_AGENT_INBOX - ].effect(function* ({ input, context, errors }) { - yield* refuseUnlessPanel(context.actor, () => errors.REFUSED()); - return yield* host.codingAgentAgentInbox(input); - }), [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_FOLLOW]: actPort[ AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_FOLLOW ].effect(function* ({ input, context, errors }) { diff --git a/packages/host/src/compose-coding-agents.test.ts b/packages/host/src/compose-coding-agents.test.ts index 98b5cd6a64..bd5e7469be 100644 --- a/packages/host/src/compose-coding-agents.test.ts +++ b/packages/host/src/compose-coding-agents.test.ts @@ -56,9 +56,9 @@ import { type GatewayEventKind, type GatewayMethod, } from "@luke/gateway"; -import { isRecord, isWireString, type WireRecord, type WireValue } from "@luke/wire"; +import { isRecord, type WireRecord, type WireValue } from "@luke/wire"; import { noNetwork } from "@luke/wire/testing"; -import { Effect, Fiber } from "effect"; +import { Effect } from "effect"; import { TestClock } from "effect/testing"; import { type CodingAgentClient, composeCodingAgents } from "./compose-coding-agents.js"; import type { LiveClient } from "./live-follow.js"; @@ -841,13 +841,6 @@ it.effect("each method asks the service once and answers what it said, whole", ( assert.deepEqual(yield* call(GATEWAY_METHOD.CODING_AGENTS_PAGE, { agentId: AGENT_ID }), { agent: PAGE, }); - assert.deepEqual( - yield* call(GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX, { - agentId: AGENT_ID, - after: INBOX_CURSOR_START, - }), - { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }, - ); assert.deepEqual( yield* call(GATEWAY_METHOD.CODING_AGENTS_PULL_REQUEST, { agentId: AGENT_ID }), PUBLISHED, @@ -913,13 +906,6 @@ it.effect("each method asks the service once and answers what it said, whole", ( yield* call(GATEWAY_METHOD.CODING_AGENTS_PULL_REQUEST_VIEWED, { agentId: AGENT_ID }), { outcome: PULL_REQUEST_WRITE_OUTCOME.DONE, files: [] }, ); - assert.deepEqual( - yield* call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { - taskId: TASK_ID, - after: INBOX_CURSOR_START, - }), - { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }, - ); assert.deepEqual( yield* call(GATEWAY_METHOD.CODING_AGENTS_INBOX_ATTACHMENT, { itemId: ITEM.id, @@ -1045,7 +1031,6 @@ it.effect("each method asks the service once and answers what it said, whole", ( `archive:${AGENT_ID}:true`, `pin:${AGENT_ID}:true`, `page:${AGENT_ID}`, - `agent-inbox-live:${AGENT_ID}`, `pull-request:${AGENT_ID}`, `changes:${AGENT_ID}`, `changes:${AGENT_ID}#11`, @@ -1060,7 +1045,6 @@ it.effect("each method asks the service once and answers what it said, whole", ( `github-image:${AGENT_ID}:https://camo.githubusercontent.com/abc`, `pull-request-action:${AGENT_ID}#11:${PULL_REQUEST_ACTION.READY_FOR_REVIEW}`, `pull-request-viewed:${AGENT_ID}`, - `plan-inbox-live:${TASK_ID}`, `attachment:${ITEM.id}:${ATTACHMENT_ID}`, `picture:${AGENT_ID}:${PICTURE_ID}:call-worker`, `answer:${ITEM.id}:2:${INBOX_REPLY.DECLINED}:Not before the freeze lifts.:answer-1`, @@ -1411,162 +1395,6 @@ const ITEM_ROW = { settledAt: null, }; -it.effect( - "a plan's inbox is answered from its Realtime subscription: at once once its rows land, then as soon as they move past the reader's cursor, and unchanged when the hold ends", - () => - Effect.gen(function* () { - const { call, asked, sealScope, realtime } = yield* subject(); - sealScope(SEALED_SCOPE); - const first = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), - ); - yield* settled; - assert.deepEqual(realtime.opened, [SEALED_SCOPE.url]); - realtime.deliver("messages", []); - realtime.deliver("files", []); - realtime.deliver("items", [ITEM_ROW]); - yield* settled; - const answered = yield* Fiber.join(first); - assert.ok(isRecord(answered) && isWireString(answered.cursor)); - assert.deepEqual(answered.items, [LIVE_ITEM]); - - // Read again from that cursor, it holds until the items move. - const next = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: answered.cursor }), - ); - yield* settled; - const closed = { - ...ITEM_ROW, - revision: 3, - status: INBOX_STATUS.CLOSED, - closedReason: "Done", - }; - realtime.deliver("items", [closed]); - yield* settled; - const moved = yield* Fiber.join(next); - assert.ok(isRecord(moved) && isWireString(moved.cursor)); - assert.notEqual(moved.cursor, answered.cursor); - assert.deepEqual(moved.items, [ - { ...LIVE_ITEM, revision: 3, status: INBOX_STATUS.CLOSED, closedReason: "Done" }, - ]); - - const unchanged = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: moved.cursor }), - ); - yield* settled; - yield* TestClock.adjust("20 seconds"); - assert.deepEqual(yield* Fiber.join(unchanged), moved); - // One subscription served every read. - assert.deepEqual(asked, [`plan-inbox-live:${TASK_ID}`]); - assert.deepEqual(realtime.opened, [SEALED_SCOPE.url]); - }), -); - -it.effect( - "a plan's inbox read past its cursor wakes at once when the reader marks an item unread, though nothing else on it moved", - () => - Effect.gen(function* () { - const { call, sealScope, realtime } = yield* subject(); - sealScope(SEALED_SCOPE); - const first = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), - ); - yield* settled; - realtime.deliver("messages", []); - realtime.deliver("files", []); - realtime.deliver("items", [ITEM_ROW]); - yield* settled; - const answered = yield* Fiber.join(first); - assert.ok(isRecord(answered) && isWireString(answered.cursor)); - - const next = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: answered.cursor }), - ); - yield* settled; - realtime.deliver("items", [{ ...ITEM_ROW, unread: true }]); - yield* settled; - const moved = yield* Fiber.join(next); - assert.ok(isRecord(moved)); - assert.notEqual(moved.cursor, answered.cursor); - assert.deepEqual(moved.items, [{ ...LIVE_ITEM, unread: true }]); - }), -); - -it.effect( - "an agent's inbox the account does not reach is not found, one the service seals nothing for is unanswered, and either is asked for again on the next read", - () => - Effect.gen(function* () { - const { call, asked, sealScope, realtime } = yield* subject(); - const read = () => - call(GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX, { - agentId: AGENT_ID, - after: INBOX_CURSOR_START, - }); - sealScope({ failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); - assert.deepEqual(yield* read(), { failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); - sealScope(undefined); - assert.deepEqual(yield* read(), { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - assert.deepEqual(asked, [`agent-inbox-live:${AGENT_ID}`, `agent-inbox-live:${AGENT_ID}`]); - assert.deepEqual(realtime.opened, []); - }), -); - -it.effect( - "a plan's inbox whose subscription stops is unanswered, and the next read subscribes again", - () => - Effect.gen(function* () { - const { call, sealScope, realtime } = yield* subject(); - sealScope(SEALED_SCOPE); - const read = () => - Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), - ); - const broken = yield* read(); - yield* settled; - realtime.deliver("messages", []); - realtime.deliver("files", []); - realtime.deliver("items", [{ id: "not an item" }]); - yield* settled; - assert.deepEqual(yield* Fiber.join(broken), { - failure: CODING_AGENT_CALL_FAILURE.UNANSWERED, - }); - - const again = yield* read(); - yield* settled; - realtime.deliver("messages", []); - realtime.deliver("files", []); - realtime.deliver("items", [ITEM_ROW]); - yield* settled; - const answered = yield* Fiber.join(again); - assert.ok(isRecord(answered)); - assert.deepEqual(answered.items, [LIVE_ITEM]); - assert.deepEqual(realtime.opened, [SEALED_SCOPE.url, SEALED_SCOPE.url]); - }), -); - -it.effect( - "a sign-out lets go at once a plan's inbox subscription kept for a reader that may come back", - () => - Effect.gen(function* () { - const { call, sealScope, realtime, reset } = yield* subject(); - sealScope(SEALED_SCOPE); - const reading = yield* Effect.forkChild( - call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), - ); - yield* settled; - realtime.deliver("messages", []); - realtime.deliver("files", []); - realtime.deliver("items", [ITEM_ROW]); - yield* Fiber.join(reading); - yield* settled; - // Read and answered, the subscription is kept a while for the next read. - assert.deepEqual(realtime.closed, []); - yield* reset; - yield* settled; - assert.deepEqual(realtime.closed, [SEALED_SCOPE.url]); - }), -); - const SEALED_CHAT: WorkspaceChatLiveAnswer = { url: "wss://realtime.example/v1", queries: { @@ -1747,7 +1575,7 @@ it.effect( realtime.deliver("messages", []); realtime.deliver("files", []); realtime.deliver("items", [ITEM_ROW]); - yield* settled; + yield* toldTimes(told, 1); realtime.deliver("items", [ITEM_ROW]); yield* settled; const closed = { @@ -1757,7 +1585,7 @@ it.effect( closedReason: "Done", }; realtime.deliver("items", [closed]); - yield* settled; + yield* toldTimes(told, 2); assert.deepEqual(told(), [ { scope: PLAN_SCOPE, inbox: { items: [LIVE_ITEM] } }, { diff --git a/packages/host/src/compose-coding-agents.ts b/packages/host/src/compose-coding-agents.ts index 43572225b8..c9f26049c3 100644 --- a/packages/host/src/compose-coding-agents.ts +++ b/packages/host/src/compose-coding-agents.ts @@ -10,7 +10,6 @@ import { type CodingAgentDefaultAnswer, type CodingAgentGitHubImageView, type CodingAgentInboxAnsweredView, - type CodingAgentInboxAnswerView, type CodingAgentInboxAttachmentView, type CodingAgentInboxChanged, type CodingAgentInboxMessagedView, @@ -28,7 +27,6 @@ import { type CodingAgentPullRequestWriteAnswerView, type CodingAgentReviewOptionsAnswerView, codingAgentAccountInboxParamsSchema, - codingAgentAgentInboxParamsSchema, codingAgentArchiveParamsSchema, codingAgentChatAttachmentParamsSchema, codingAgentChatFollowParamsSchema, @@ -40,7 +38,6 @@ import { codingAgentInboxAttachmentParamsSchema, codingAgentInboxCloseParamsSchema, codingAgentInboxMessageParamsSchema, - codingAgentInboxParamsSchema, codingAgentInboxReadStateParamsSchema, codingAgentInboxScopeSchema, codingAgentListParamsSchema, @@ -104,7 +101,6 @@ import type { RunMode } from "./run-mode.js"; import { followInbox, InboxScopeKey, - inboxPast, type ScopeInboxes, scopeInboxes, } from "./scope-inbox-follow.js"; @@ -119,21 +115,17 @@ import { /** * 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 transcripts' subscriptions and each followed - * workspace chat's (`chat-follow.ts`), whose changes are heard as - * `CHAT_CHANGED` events. 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 held open here while the agent runs and - * nothing new stands, on a Neon Realtime subscription to what its pages are - * read from (`transcript-follow.ts`), which a sign-out lets go; the inbox - * read is one the service holds open, 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 - * the account is held the same way, and asked by main's own loop rather - * than a window's (`agent-notices.ts`). Behind a + * Nothing is held here but the transcripts' subscriptions, the followed + * inboxes' (`scope-inbox-follow.ts`), heard as `INBOX_CHANGED` events, and + * each followed workspace chat's (`chat-follow.ts`), heard as `CHAT_CHANGED` + * events. 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 held open here while + * the agent runs and nothing new stands, on a Neon Realtime subscription to + * what its pages are read from (`transcript-follow.ts`), which a sign-out + * lets go. What waits across the account is held the same way, and asked by + * main's own loop rather than a window's (`agent-notices.ts`). Behind a * closed account gate, or on a run that sends nothing, every ask answers * unanswered. A provider key passes through on its way to the service and * is held nowhere here; no answer carries one back. @@ -771,38 +763,6 @@ function composed( const answer = yield* asked(client.githubImage(agentId, src)); return carried(answer); }), - [GATEWAY_METHOD.CODING_AGENTS_INBOX]: (params) => - Effect.gen(function* () { - const { taskId, after } = yield* read( - codingAgentInboxParamsSchema, - params, - "reading a task's inbox names the task and a cursor", - ); - const answer = gate() - ? yield* inboxPast( - inboxes, - new InboxScopeKey({ kind: INBOX_SCOPE_KIND.PLAN, id: taskId }), - after, - ) - : UNANSWERED; - return carried(answer); - }), - [GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX]: (params) => - Effect.gen(function* () { - const { agentId, after } = yield* read( - codingAgentAgentInboxParamsSchema, - params, - "reading an agent's inbox names the agent and a cursor", - ); - const answer = gate() - ? yield* inboxPast( - inboxes, - new InboxScopeKey({ kind: INBOX_SCOPE_KIND.AGENT, id: agentId }), - after, - ) - : UNANSWERED; - return carried(answer); - }), [GATEWAY_METHOD.CODING_AGENTS_INBOX_FOLLOW]: (params) => Effect.gen(function* () { const scope = yield* read( diff --git a/packages/host/src/contract/agent-inbox.ts b/packages/host/src/contract/agent-inbox.ts index 041a4ef545..cfce32452a 100644 --- a/packages/host/src/contract/agent-inbox.ts +++ b/packages/host/src/contract/agent-inbox.ts @@ -2,7 +2,7 @@ * agent-inbox.ts -- the Inbox's and the workspace chat's asks of the host, as procedures. * * What a task's coding agents need of the developer, and what they say in - * the workspace: an Inbox read, one item answered, closed, messaged, or + * the workspace: a task's or an agent's inbox followed, one item answered, closed, messaged, or * marked, every item marked read, an item's image and an agent's picture, * and the workspace's chat read, written to, and a message's picture. * @@ -20,20 +20,17 @@ import { type CodingAgentChatAnswerView, type CodingAgentInboxChanged, - codingAgentAgentInboxParamsSchema, codingAgentChatAttachmentParamsSchema, codingAgentChatFollowParamsSchema, codingAgentChatPostParamsSchema, codingAgentChatWriteAnswerViewSchema, codingAgentInboxAnsweredViewSchema, codingAgentInboxAnswerParamsSchema, - codingAgentInboxAnswerViewSchema, codingAgentInboxAttachmentParamsSchema, codingAgentInboxAttachmentViewSchema, codingAgentInboxCloseParamsSchema, codingAgentInboxMessagedViewSchema, codingAgentInboxMessageParamsSchema, - codingAgentInboxParamsSchema, codingAgentInboxReadAllViewSchema, codingAgentInboxReadStateParamsSchema, codingAgentInboxReadStateViewSchema, @@ -47,16 +44,6 @@ import { actProcedure, exactInput } from "./base.js"; import { AGENT_INBOX_ACT_KIND } from "./kinds.js"; export const agentInboxActsContract = { - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX]: actProcedure( - "Could not read the task's inbox on this system.", - ) - .input(exactInput(codingAgentInboxParamsSchema)) - .output(EffectSchema.toType(codingAgentInboxAnswerViewSchema)), - [AGENT_INBOX_ACT_KIND.CODING_AGENTS_AGENT_INBOX]: actProcedure( - "Could not read the agent's inbox on this system.", - ) - .input(exactInput(codingAgentAgentInboxParamsSchema)) - .output(EffectSchema.toType(codingAgentInboxAnswerViewSchema)), [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_FOLLOW]: actProcedure( "Could not follow the inbox on this system.", ) diff --git a/packages/host/src/contract/kinds.ts b/packages/host/src/contract/kinds.ts index 9366b3a1c8..9cb24b14ae 100644 --- a/packages/host/src/contract/kinds.ts +++ b/packages/host/src/contract/kinds.ts @@ -162,8 +162,6 @@ export const AGENT_ACT_KIND = { * window holds what it draws; nothing here does. */ export const AGENT_INBOX_ACT_KIND = { - CODING_AGENTS_INBOX: "codingAgents.inbox", - CODING_AGENTS_AGENT_INBOX: "codingAgents.agentInbox", /** * A task's inbox, or one agent's, followed while the window holds the * stream open: what it holds, whole, each time it changes, or why there diff --git a/packages/host/src/host-operator.ts b/packages/host/src/host-operator.ts index fa5b82c056..539f1dbfb6 100644 --- a/packages/host/src/host-operator.ts +++ b/packages/host/src/host-operator.ts @@ -34,7 +34,6 @@ import { type CodingAgentAccountInboxAnswerView, type CodingAgentAccountInboxParams, type CodingAgentAgentAnswer, - type CodingAgentAgentInboxParams, type CodingAgentArchiveParams, type CodingAgentCallsAnswerView, type CodingAgentChangesAnswerView, @@ -48,14 +47,12 @@ import { type CodingAgentGitHubImageView, type CodingAgentInboxAnsweredView, type CodingAgentInboxAnswerParams, - type CodingAgentInboxAnswerView, type CodingAgentInboxAttachmentParams, type CodingAgentInboxAttachmentView, type CodingAgentInboxChanged, type CodingAgentInboxCloseParams, type CodingAgentInboxMessagedView, type CodingAgentInboxMessageParams, - type CodingAgentInboxParams, type CodingAgentInboxReadAllView, type CodingAgentInboxReadStateParams, type CodingAgentInboxReadStateView, @@ -97,7 +94,6 @@ import { codingAgentDefaultAnswerViewSchema, codingAgentGitHubImageViewSchema, codingAgentInboxAnsweredViewSchema, - codingAgentInboxAnswerViewSchema, codingAgentInboxAttachmentViewSchema, codingAgentInboxChangedSchema, codingAgentInboxMessagedViewSchema, @@ -767,12 +763,6 @@ export interface HostOperator { dictationTranscribe( params: DictationTranscribeParams, ): Effect.Effect; - /** One task's inbox past a cursor, held by the service while nothing changed; or why there is none. */ - codingAgentInbox(params: CodingAgentInboxParams): Effect.Effect; - /** One agent's inbox past a cursor, an agent with no task as much as one on a task, held as a task's is; or why there is none. */ - codingAgentAgentInbox( - params: CodingAgentAgentInboxParams, - ): Effect.Effect; /** One task's or agent's inbox, followed from now until let go or signed out, heard through `onInboxChanged`; following again starts over. */ codingAgentInboxFollow(scope: CodingAgentInboxScope): Effect.Effect; /** One task's or agent's inbox let go: nothing more is heard of it until it is followed again. */ @@ -2166,22 +2156,6 @@ export function createHostOperator(options: HostOperatorOptions): HostOperator { }), codingAgentAnswer(dictationTranscribeAnswerViewSchema), ), - codingAgentInbox: (params) => - Effect.map( - client.call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { - taskId: params.taskId, - after: params.after, - }), - codingAgentAnswer(codingAgentInboxAnswerViewSchema), - ), - codingAgentAgentInbox: (params) => - Effect.map( - client.call(GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX, { - agentId: params.agentId, - after: params.after, - }), - codingAgentAnswer(codingAgentInboxAnswerViewSchema), - ), codingAgentInboxFollow: (scope) => fire( client.call(GATEWAY_METHOD.CODING_AGENTS_INBOX_FOLLOW, { kind: scope.kind, id: scope.id }), diff --git a/packages/host/src/scope-inbox-follow.ts b/packages/host/src/scope-inbox-follow.ts index 2fe6f648d9..e0dac093c8 100644 --- a/packages/host/src/scope-inbox-follow.ts +++ b/packages/host/src/scope-inbox-follow.ts @@ -1,6 +1,5 @@ import { CODING_AGENT_CALL_FAILURE, - type CodingAgentInboxAnswerView, type CodingAgentInboxChanged, type INBOX_SCOPE_KIND, } from "@luke/features/coding-agent-view"; @@ -26,23 +25,16 @@ import { Hex } from "effect/encoding"; import { LiveBroken, type LiveClient, liveRows } from "./live-follow.js"; /** - * scope-inbox-follow.ts -- a plan's inbox, or one agent's, read by the window as before but answered from a Neon Realtime subscription the host holds while the window reads it. + * scope-inbox-follow.ts -- a plan's inbox, or one agent's, followed for main from a Neon Realtime subscription the host holds while it is followed, and told whole as it changes. * - * The window still asks with the cursor it was last answered, and is - * answered as soon as the inbox moves past it, or with the inbox unchanged - * once the hold ends, as the service's held read answered it. What changed - * is where the answer comes from: the host subscribes to the scope's sealed - * statements (`live-follow.ts`) the first time a window asks, keeps the - * items as each change lands, and lets the subscription go a while after - * the last read let go of it. Nothing asks the service on a clock. A scope - * the account does not reach is not found, as the held read said; a seal - * the service did not answer, or a subscription that stopped, is answered - * unanswered and let go, so the window's next read subscribes again. + * The host subscribes to the scope's sealed statements (`live-follow.ts`) + * the first time the scope is followed, keeps the items as each change + * lands, tells each change, and lets the subscription go a while after the + * last follow let go of it. Nothing asks the service on a clock. One the + * account does not reach is not found, as the held read said. */ -/** How long a read is held while nothing moved 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 window coming back finds it standing. */ const IDLE = Duration.seconds(30); /** How long after a seal went unanswered, or a subscription stopped, a followed scope is sealed again. */ const LIVE_RESTART = Duration.seconds(5); @@ -162,42 +154,6 @@ export function scopeInboxes( }); } -/** - * The scope's inbox past the reader's cursor: at once where it moved, or - * once it does within the hold, or unchanged once the hold ends. A scope - * refused, or whose subscription stopped or had no rows within the hold, - * is answered why and let go, so the next read subscribes again. - */ -export function inboxPast( - inboxes: ScopeInboxes, - scope: InboxScopeKey, - after: string, -): Effect.Effect { - return Effect.scoped( - Effect.gen(function* () { - const standing = yield* RcMap.get(inboxes, scope); - if (standing.kind === "refused") { - yield* RcMap.invalidate(inboxes, scope); - return { failure: standing.failure }; - } - const moved = yield* SubscriptionRef.changes(standing.inbox).pipe( - Stream.filter( - (state) => - state.kind === "broken" || (state.kind === "standing" && state.inbox.cursor !== after), - ), - Stream.runHead, - Effect.timeoutOption(HOLD), - Effect.map(Option.flatten), - ); - const state = Option.isSome(moved) ? moved.value : yield* SubscriptionRef.get(standing.inbox); - if (state.kind === "standing") - return { items: state.inbox.items, cursor: state.inbox.cursor }; - yield* RcMap.invalidate(inboxes, scope); - return { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; - }), - ); -} - /** A followed inbox as the window is told it: its items whole, or why there are none. */ type Told = CodingAgentInboxChanged["inbox"]; diff --git a/packages/panel/src/composer-drafts.test.ts b/packages/panel/src/composer-drafts.test.ts index 046de74b8e..87e0639038 100644 --- a/packages/panel/src/composer-drafts.test.ts +++ b/packages/panel/src/composer-drafts.test.ts @@ -1,9 +1,9 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentSummary } from "@luke/features/coding-agent-wire"; import { - INBOX_CURSOR_START, INBOX_RESPONSE, INBOX_STATUS, INBOX_WIRE_VERSION, @@ -43,6 +43,7 @@ import { } from "./testing/composer.js"; import { onRegistry } from "./testing/copy-feedback.js"; import { sidebarProps } from "./testing/desktop-sidebar.js"; +import { followedInboxes } from "./testing/followed-inboxes.js"; import { installScrollIntoView } from "./testing/scroll-into-view.js"; import { codingAgentsControl, tasksControl, UNPINNED_SUMMARY } from "./testing/tasks-control.js"; @@ -589,10 +590,7 @@ async function columnOn( ...codingAgentsControl({ agents: [agent], agentIds: [agent.id], - readInbox: ({ after }) => - after === INBOX_CURSOR_START - ? Promise.resolve({ items: [approval(1)], cursor: "1" }) - : new Promise(() => undefined), + inboxFollower: followedInboxes(() => ({ items: [approval(1)] })).follower, }), draft: { id: DRAFT_KEY, @@ -753,6 +751,7 @@ function approval(revision: number): InboxItem { */ async function inboxOn(item: InboxItem) { answerMotion(); + const main = followedInboxes(() => ({ items: [item] })); const container = document.body.appendChild(document.createElement("div")); const root = createRoot(container); roots.push(root); @@ -765,10 +764,7 @@ async function inboxOn(item: InboxItem) { agents: codingAgentsControl({ agents: [INVITES.agent], agentIds: [INVITES.agent.id], - readInbox: ({ after }) => - after === INBOX_CURSOR_START - ? Promise.resolve({ items: [item], cursor: "1" }) - : new Promise(() => undefined), + inboxFollower: main.follower, }), }); await act(async () => @@ -835,21 +831,19 @@ function tasklessItem(agentId: string, id: string, revision: number): InboxItem return { ...approval(revision), id, agentId }; } -/** An agent's own inbox with no plan, read through its reader as the Inbox page reads it, answering `items`. */ +/** An agent's own inbox with no task, followed as the Inbox page follows it, pushed as `items`. */ async function tasklessInbox(agentId: string, items: readonly InboxItem[]) { + const main = followedInboxes(() => ({ items })); let control: TaskInboxControl | undefined; function Probe(): null { control = useTaskInbox({ taskId: agentId, - reader: (after) => - after === INBOX_CURSOR_START - ? Promise.resolve({ items, cursor: "1" }) - : new Promise(() => undefined), + kind: INBOX_SCOPE_KIND.AGENT, shown: true, waiting: true, agents: { heldIds: [agentId], - readInbox: () => new Promise(() => undefined), + inboxFollower: main.follower, answerInbox: () => new Promise(() => undefined), messageInbox: () => new Promise(() => undefined), readInboxImage: () => new Promise(() => undefined), diff --git a/packages/panel/src/desktop/desktop-factory.test.ts b/packages/panel/src/desktop/desktop-factory.test.ts index 09e6afd5e3..4d85935da0 100644 --- a/packages/panel/src/desktop/desktop-factory.test.ts +++ b/packages/panel/src/desktop/desktop-factory.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; +import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; import { CHECK_SUMMARY, CODING_AGENT_STATUS, @@ -16,7 +16,6 @@ import { } from "@luke/features/inbox-wire"; import { LINK_PLACE, type ReadLink } from "@luke/features/luke-link"; import type { TaskSummary } from "@luke/features/task-wire"; -import { ACT_KIND } from "@luke/host/acts"; import { EXPERIMENT, type Experiment } from "@luke/settings"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { act, createElement } from "react"; @@ -24,14 +23,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, test } from "vitest"; import { OpenFeedbackProvider } from "../open-feedback.js"; import type { InboxChoice, TasksControl } from "../tasks/use-tasks-tab.js"; -import { - fakeActPort, - fakeActPortCalls, - fakeAppBridge, - refusedOnPort, -} from "../testing/app-bridge.js"; +import { fakeActPort, fakeActPortCalls, fakeAppBridge } from "../testing/app-bridge.js"; import { copyingInto, fakeAppPlatform, QUIET_PANEL } from "../testing/app-platform.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { tasksControl } from "../testing/tasks-control.js"; import { DesktopFactory } from "./desktop-factory.js"; @@ -582,16 +577,7 @@ test("a question a teammate's agent asks, which main does not count as the reade postedAt: NOW - HOUR, updatedAt: NOW - HOUR, }; - fakeActPort({ - ...fakeActPortCalls(), - [ACT_KIND.CODING_AGENTS_INBOX]: ({ taskId }) => { - const value: CodingAgentInboxAnswerView = { - items: taskId === BILLING.id ? [asked] : [], - cursor: "1", - }; - return Promise.resolve(value); - }, - }); + answerInbox((taskId) => (taskId === BILLING.id ? [asked] : [])); const { page } = mount([SEARCH, BILLING], { // Main's own count holds nothing: the agent is a teammate's. inbox: [], @@ -609,24 +595,38 @@ test("a question a teammate's agent asks, which main does not count as the reade ]); }); -/** The act port answering a plan's Inbox read as `answer` says, counting the reads. */ +/** The question main counts on Billing, answered since, as the task's inbox holds it. */ +const ANSWERED: InboxItem = { + id: "i1", + agentId: AGENT_ID, + key: "1", + revision: 2, + version: INBOX_WIRE_VERSION, + title: QUESTION, + blocks: [], + response: { type: INBOX_RESPONSE.APPROVAL }, + needsReply: true, + status: INBOX_STATUS.ANSWERED, + postedAt: NOW - HOUR, + updatedAt: NOW - HOUR, +}; + +/** The host pushing a task's inbox as `answer` says once it is followed, or that it could not be read where it says nothing, counting the follows. */ function answerInbox(answer: (taskId: string) => readonly InboxItem[] | undefined) { - const counted = { reads: 0 }; - fakeActPort({ - ...fakeActPortCalls(), - [ACT_KIND.CODING_AGENTS_INBOX]: ({ taskId }) => { - counted.reads += 1; - const items = answer(taskId); - if (items === undefined) return Promise.reject(refusedOnPort("No service.")); - const value: CodingAgentInboxAnswerView = { items, cursor: "1" }; - return Promise.resolve(value); - }, + const host = followedInboxes(({ id }) => { + const items = answer(id); + return items === undefined ? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } : { items }; }); - return counted; + fakeActPort({ ...fakeActPortCalls(), ...host.procedures }); + return { + get reads() { + return host.asked.length; + }, + }; } test("a question answered since main last counted it leaves Build once the Inbox's read says so", async () => { - answerInbox(() => []); + answerInbox(() => [ANSWERED]); const { page } = mount([SEARCH, BILLING], { inbox: [ { @@ -676,7 +676,7 @@ test("questions that could not be read say so with Retry, and the reader's own s }); test("one plan's failed Inbox read keeps main's count for that plan alone: a question another plan's read shows answered stays gone", async () => { - const counted = answerInbox((taskId) => (taskId === BILLING.id ? [] : undefined)); + const counted = answerInbox((taskId) => (taskId === BILLING.id ? [ANSWERED] : undefined)); const other = task(7, "Invites", HOUR); const { page } = mount([SEARCH, BILLING, other], { inbox: [ @@ -722,9 +722,8 @@ test("a question read that fails says so even while a refresh of the task list h assert.ok(alerts.some((each) => /Your tasks could not be read\./u.test(each))); }); -test("a question main counts after a plan's last read stands while that plan's reread is out or failed", async () => { - let fail = false; - answerInbox(() => (fail ? undefined : [])); +test("a question main counts later than a plan's last push stands until the plan's push catches up", async () => { + answerInbox(() => [ANSWERED]); const listed = (updatedAt: number) => ({ tasks: [SEARCH, BILLING], archived: [], @@ -738,8 +737,7 @@ test("a question main counts after a plan's last read stands while that plan's r const { page, redraw } = mount([SEARCH, BILLING], { listed: listed(NOW - HOUR) }); await settle(); assert.deepEqual(rows(page, "build"), ["Plan index · Search across plans"]); - // Main counts a new question on the plan; its reread fails. - fail = true; + // Main counts a new question on the plan before the plan's own push of it lands. redraw({ listed: listed(NOW), inbox: [asked] }); await settle(); assert.equal(rows(page, "build")[0], `${QUESTION} · Mercury payouts (waits)`); diff --git a/packages/panel/src/desktop/desktop-home.test.ts b/packages/panel/src/desktop/desktop-home.test.ts index cd31c800b5..1ae783246a 100644 --- a/packages/panel/src/desktop/desktop-home.test.ts +++ b/packages/panel/src/desktop/desktop-home.test.ts @@ -8,7 +8,7 @@ import { AUTOMATION_TRIGGER, type AutomationSummary, } from "@luke/features/automation-wire"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS } from "@luke/features/coding-agent-wire"; import { INBOX_RESPONSE, @@ -40,6 +40,7 @@ import { } from "../testing/composer.js"; import { layContainersAt } from "../testing/container-width.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; import { tasksControl, waitingOf } from "../testing/tasks-control.js"; import { commandKeyshortcuts } from "../tooltip.js"; @@ -133,7 +134,7 @@ const SWEEP: AutomationSummary = { scope: AUTOMATION_SCOPE.WORKSPACE, }; -/** Each agent with no task's inbox as the service answers a read of it. */ +/** Each agent with no task's inbox as main pushes it. */ const AGENT_INBOXES = new Map([[SWEEP_AGENT, [QUARANTINE]]]); /** The tasks holding items, and those of them with an item waiting, as main reads the account. */ @@ -184,18 +185,12 @@ beforeEach(() => { installScrollIntoView(); installEditorLayout(); fakeAppBridge({}); + // The host pushes each task's inbox, and each agent's with no task, as it is followed. + const host = followedInboxes(({ kind, id }) => ({ + items: (kind === INBOX_SCOPE_KIND.PLAN ? INBOXES : AGENT_INBOXES).get(id) ?? [], + })); fakeActPort({ - [ACT_KIND.CODING_AGENTS_AGENT_INBOX]: ({ agentId }) => { - const value: CodingAgentInboxAnswerView = { - items: AGENT_INBOXES.get(agentId) ?? [], - cursor: "1", - }; - return Promise.resolve(value); - }, - [ACT_KIND.CODING_AGENTS_INBOX]: ({ taskId }) => { - const value: CodingAgentInboxAnswerView = { items: INBOXES.get(taskId) ?? [], cursor: "1" }; - return Promise.resolve(value); - }, + ...host.procedures, [ACT_KIND.WORKSPACE_MEMBERS_READ]: () => Promise.resolve(MEMBERS), [ACT_KIND.AUTOMATIONS_LIST]: (payload) => { automationReads.push(payload.workspaceId ?? ""); diff --git a/packages/panel/src/desktop/desktop-inbox.test.ts b/packages/panel/src/desktop/desktop-inbox.test.ts index 21fe868ec7..4bdb30ecb5 100644 --- a/packages/panel/src/desktop/desktop-inbox.test.ts +++ b/packages/panel/src/desktop/desktop-inbox.test.ts @@ -16,6 +16,7 @@ import { type CodingAgentInboxMessagedView, type CodingAgentInboxMessageParams, type CodingAgentListAnswer, + INBOX_SCOPE_KIND, } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, type CodingAgentSummary } from "@luke/features/coding-agent-wire"; import { inboxAttachmentAddress } from "@luke/features/inbox-attachment-link"; @@ -24,7 +25,6 @@ import { INBOX_ASK_STATUS, INBOX_BLOCK, INBOX_BOUNDS, - INBOX_CURSOR_START, INBOX_FIELD, INBOX_IMAGE_TYPE, INBOX_MESSAGE_AUTHOR, @@ -71,6 +71,8 @@ import { type AnyAct, FAKE_ANSWER, type FakeActAnswer, + fakeActPort, + fakeActPortCalls, fakeActs, fakeActsAnswer, } from "../testing/app-bridge.js"; @@ -89,6 +91,7 @@ import { typeInComposer, } from "../testing/composer.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; import { relayout } from "../testing/resize-observer.js"; import { installScrollIntoView } from "../testing/scroll-into-view.js"; @@ -237,8 +240,6 @@ interface ReadOutcome { readonly value: CodingAgentInboxAnswerView; } -/** How the next held read of a task, one past the cursor, answers: held open while nothing changes unless a test says. */ -let heldRead: (taskId: string) => Promise = () => new Promise(() => undefined); /** How a read of a task from the start answers, where a test holds it in its own hands. */ let startRead: ((taskId: string) => Promise | undefined) | undefined; /** Each agent with no task's items, as its own inbox read answers them. */ @@ -252,6 +253,51 @@ let automations: readonly AutomationSummary[] = []; let copied: string[] = []; /** Each agent with no plan as a read of its own page answers it. */ let pageAgents = new Map(); +/** Main's follows for the window, each followed inbox pushed as the knobs above say. */ +let main = followedInboxes(() => undefined); + +/** A read's answer as main pushes it: the items, or why there are none. */ +function pushedOf(answer: CodingAgentInboxAnswerView) { + return "failure" in answer ? { failure: answer.failure } : { items: answer.items }; +} + +/** A task's inbox as main pushes it now: as `startRead` settles it, where it holds one, or as `inboxes` holds it. */ +function answerTask(taskId: string) { + const held = startRead?.(taskId); + if (held === undefined) return { items: inboxes.get(taskId) ?? [] }; + void held.then((outcome) => + main.push({ kind: INBOX_SCOPE_KIND.PLAN, id: taskId }, pushedOf(outcome.value)), + ); + return undefined; +} + +/** An agent's inbox as main pushes it now, the push counted: refused, held in the test's hands, or as `agentInboxes` holds it. */ +function answerAgent(agentId: string) { + agentReads.push(agentId); + if (agentReadFails) return { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; + const items = agentInboxes.get(agentId) ?? []; + const hold = holdAgentRead; + if (hold === undefined) return { items }; + hold(() => main.push({ kind: INBOX_SCOPE_KIND.AGENT, id: agentId }, { items })); + return undefined; +} + +/** Every followed inbox pushed again as it now stands, as main pushes a change. */ +function pushAgain(): void { + for (const taskId of [...inboxes.keys(), BILLING.id, INVITES.id]) { + const scope = { kind: INBOX_SCOPE_KIND.PLAN, id: taskId }; + if (!main.isFollowed(scope)) continue; + const inbox = answerTask(taskId); + if (inbox !== undefined) main.push(scope, inbox); + } + for (const agentId of agentInboxes.keys()) { + const scope = { kind: INBOX_SCOPE_KIND.AGENT, id: agentId }; + if (!main.isFollowed(scope)) continue; + const inbox = answerAgent(agentId); + if (inbox !== undefined) main.push(scope, inbox); + } +} + /** The reads of an item's images, each held until the test lands it with a PNG's first bytes. */ let imageReads: (() => void)[] = []; /** Every read mark the Inbox sent, in order: `read:`, `unread:`, or `all:`. */ @@ -278,7 +324,6 @@ beforeEach(() => { [BILLING.id, [THRESHOLD, RETRY]], [INVITES.id, [EXPIRY]], ]); - heldRead = () => new Promise(() => undefined); startRead = undefined; taskPages = new Map([[BILLING.id, [FUNNEL]]]); listedAgents = [AGENT]; @@ -296,6 +341,9 @@ beforeEach(() => { readMarks = []; readMarkRefused = false; members = []; + main = followedInboxes((scope) => + scope.kind === INBOX_SCOPE_KIND.PLAN ? answerTask(scope.id) : answerAgent(scope.id), + ); fakeActs({ act: (request: AnyAct): Promise => { if (request.kind === ACT_KIND.AUTOMATIONS_LIST) { @@ -307,7 +355,7 @@ beforeEach(() => { if (request.kind === ACT_KIND.WORKSPACE_MEMBERS_READ) { return Promise.resolve({ status: FAKE_ANSWER.DONE, value: members }); } - const done = (value: CodingAgentListAnswer | CodingAgentInboxAnswerView) => + const done = (value: CodingAgentListAnswer) => Promise.resolve({ status: FAKE_ANSWER.DONE, value }); if (request.kind === ACT_KIND.CODING_AGENTS_LIST) { return agentListHeld ? new Promise(() => undefined) : done({ agents: listedAgents }); @@ -380,29 +428,10 @@ beforeEach(() => { ), ); } - if (request.kind === ACT_KIND.CODING_AGENTS_AGENT_INBOX) { - const { agentId, after } = request.payload; - if (after !== INBOX_CURSOR_START) return new Promise(() => undefined); - agentReads.push(agentId); - if (agentReadFails) return done({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); - const items = agentInboxes.get(agentId) ?? []; - const hold = holdAgentRead; - if (hold !== undefined) { - return new Promise((resolve) => - hold(() => resolve({ status: FAKE_ANSWER.DONE, value: { items, cursor: "1" } })), - ); - } - return done({ items, cursor: "1" }); - } - if (request.kind === ACT_KIND.CODING_AGENTS_INBOX) { - const { taskId, after } = request.payload; - // A read past the cursor is held open while nothing changes, as the service holds it. - if (after !== INBOX_CURSOR_START) return heldRead(taskId); - return startRead?.(taskId) ?? done({ items: inboxes.get(taskId) ?? [], cursor: "1" }); - } return Promise.resolve({ status: FAKE_ANSWER.REFUSED, reason: "Not in this test." }); }, }); + fakeActPort({ ...fakeActPortCalls(), ...main.procedures }); }); afterEach(() => { @@ -443,6 +472,8 @@ function mount( sidebar?: boolean; } = {}, ) { + // Note that the host's inbox streams are answered beside whatever acts the test answers by now. + fakeActPort({ ...fakeActPortCalls(), ...main.procedures }); const asked: Asked = { shownTasks: [], shownAgents: [], @@ -461,9 +492,16 @@ function mount( const [listed, setListed] = useState(listUnread ? [] : standing); const [listRead, setRead] = useState(!listUnread); setListRead = setRead; - setHolding = setHeld; + // Note that main's word of a change comes with the change pushed, as the host follows both. + setHolding = (next) => { + setHeld(next); + pushAgain(); + }; const [taskless, setWaiting] = useState(firstTaskless); - setTaskless = setWaiting; + setTaskless = (next) => { + setWaiting(next); + pushAgain(); + }; setTasks = setListed; const control = tasksControl({ me: READER.userId, @@ -558,7 +596,7 @@ function SharedListing({ tasks }: { tasks: TasksControl }) { /** Lets every promise settled so far run its continuations. */ async function settle(): Promise { await act(async () => { - for (let tick = 0; tick < 12; tick += 1) await Promise.resolve(); + for (let tick = 0; tick < 40; tick += 1) await Promise.resolve(); }); } @@ -3431,11 +3469,9 @@ test("files whose reads land together both stand in the box", async () => { }); test("files read for an item its agent changed meanwhile land nowhere, and the box says to attach them again", async () => { - let deliver: (outcome: ReadOutcome) => void = () => undefined; - heldRead = () => - new Promise((resolve) => { - deliver = resolve; - }); + // Main pushes the chosen task's inbox again as the test says it changed. + const deliver = (outcome: ReadOutcome) => + main.push({ kind: INBOX_SCOPE_KIND.PLAN, id: BILLING.id }, pushedOf(outcome.value)); const { page } = mount(); await settle(); const slow = gatedFile("trace.txt", "boom\n"); @@ -3987,11 +4023,6 @@ test("a note answered on an item that needs no reply reaches the agent, so the w assert.ok(listReads.length > 0, "the agent heard the note, so its list is read again"); }); -/** An answer the act gives the renderer, the view it carries settled now. */ -function answered(value: CodingAgentInboxAnswerView): Promise { - return Promise.resolve({ status: FAKE_ANSWER.DONE, value }); -} - /** A read the test answers when it chooses. */ interface PendingRead { readonly promise: Promise; @@ -4007,10 +4038,6 @@ function pending(): PendingRead { return { promise, settle }; } -const UNANSWERED_READ: CodingAgentInboxAnswerView = { - failure: CODING_AGENT_CALL_FAILURE.UNANSWERED, -}; - test("a chosen item whose plan leaves the account's list, archived on another Mac, gives way to the first item still waiting", async () => { const { page } = mount(); await settle(); @@ -4030,74 +4057,6 @@ test("a chosen item whose plan leaves the account's list, archived on another Ma ); }); -test("once the chosen plan's own held read fails, the list's read of that plan stands again, so an item posted since still lists", async () => { - heldRead = () => answered(UNANSWERED_READ); - const { page } = mount(); - await settle(); - const posted = item("a1b2c3d4-0000-4000-8000-000000000004", "Page on-call too?", 7_000); - inboxes.set(BILLING.id, [THRESHOLD, RETRY, posted]); - act(() => - setHolding([ - { taskId: BILLING.id, items: 3, updatedAt: 7_000 }, - { taskId: INVITES.id, items: 1, updatedAt: 4_000 }, - ]), - ); - await settle(); - assert.ok(listed(page).includes("*Page on-call too?"), listed(page).join(" | ")); -}); - -test("once the chosen plan's own held read fails, an image the list's read draws since still lands", async () => { - heldRead = () => answered(UNANSWERED_READ); - const { page } = mount(); - await settle(); - const shot = { - type: INBOX_BLOCK.IMAGE, - attachmentId: "5e8d2c1a-7b3f-4a6e-9c2d-1f4b6a8e3c72", - alt: "The retry schedule", - }; - inboxes.set(BILLING.id, [{ ...THRESHOLD, revision: 2, blocks: [shot] }, RETRY]); - act(() => - setHolding([ - { taskId: BILLING.id, items: 2, updatedAt: 7_000 }, - { taskId: INVITES.id, items: 1, updatedAt: 4_000 }, - ]), - ); - await settle(); - for (const land of imageReads.splice(0)) land(); - await settle(); - assert.equal( - page.querySelector("[data-inbox-image] img")?.alt, - "The retry schedule", - ); -}); - -test("an answer to a plan's read that a newer one overtook moves nothing: the newer read's failure still says so with Try again", async () => { - const first = pending(); - const second = pending(); - const reads = [first, second]; - startRead = (taskId) => (taskId === INVITES.id ? reads.shift()?.promise : undefined); - const { page } = mount(); - await settle(); - act(() => - setHolding([ - { taskId: BILLING.id, items: 2, updatedAt: 5_000 }, - { taskId: INVITES.id, items: 1, updatedAt: 6_000 }, - ]), - ); - await settle(); - second.settle(UNANSWERED_READ); - await settle(); - first.settle({ items: [EXPIRY], cursor: "1" }); - await settle(); - const note = page.querySelector('.inbox-tasks [role="alert"]'); - assert.match(note?.textContent ?? "", /Some items could not be read\./u); - assert.equal( - listed(page).includes("*Expire after 7 days?"), - false, - "the overtaken read lists nothing", - ); -}); - /** An agent with no task an automation started, and the question it asked. */ const SWEEP_AGENT = "5e6f7081-92a3-4b4c-8d5e-6f708192a3b4"; const QUARANTINE: InboxItem = { @@ -4326,29 +4285,6 @@ test("a read of an agent with no plan that fails is said with Retry, which reads assert.doesNotMatch(page.querySelector(".inbox-tasks")?.textContent ?? "", /could not be read/u); }); -test("an agent whose waiting items come back to what it was read for lets go of the read asked between, so its late answer draws nothing", async () => { - agentInboxes = new Map([[SECOND_AGENT, [SECOND_ASK, THIRD_ASK]]]); - const { page } = mount([QUARANTINE, SECOND_ASK, THIRD_ASK].map(waitingTaskless)); - await settle(); - // The read asked for the items between is held, to land only after they came back. - let landBetween: () => void = () => undefined; - const answered = agentInboxes; - agentInboxes = new Map([[SECOND_AGENT, []]]); - holdAgentRead = (respond) => { - landBetween = respond; - }; - act(() => setTaskless([QUARANTINE, THIRD_ASK].map(waitingTaskless))); - await settle(); - holdAgentRead = undefined; - agentInboxes = answered; - act(() => setTaskless([QUARANTINE, SECOND_ASK, THIRD_ASK].map(waitingTaskless))); - await settle(); - act(() => landBetween()); - await settle(); - - assert.ok(listed(page).includes("*Skip the slow suite?"), listed(page).join(" | ")); -}); - test("an item chosen by a notification's click, in an Inbox showing its list alone, is shown itself, and Back stands on the list again, the notification's ask taken once", async () => { agentInboxes = new Map([[SECOND_AGENT, [SECOND_ASK, THIRD_ASK]]]); const { page } = mount([QUARANTINE, SECOND_ASK, THIRD_ASK].map(waitingTaskless)); @@ -5154,11 +5090,9 @@ test("a thread read cut to its latest messages says the earlier ones are in the }); test("an answer left unsent on an item a teammate answers meanwhile is let go rather than becoming a message, and a message being written on a settled item outlasts the reads after it", async () => { - let deliver: (outcome: ReadOutcome) => void = () => undefined; - heldRead = () => - new Promise((resolve) => { - deliver = resolve; - }); + // Main pushes the chosen task's inbox again as the test says it changed. + const deliver = (outcome: ReadOutcome) => + main.push({ kind: INBOX_SCOPE_KIND.PLAN, id: BILLING.id }, pushedOf(outcome.value)); const { page } = mount(); await settle(); const pane = itemPane(page); @@ -5508,7 +5442,7 @@ test("Mark all as read in the view's menu, under Group by, marks every unread it assert.equal(menuItem("Mark all as read").matches(":disabled"), true); }); -test("Mark all as read, once the service took it, reads every listed task and every agent with no task again, since the write moves nothing main hears of", async () => { +test("Mark all as read sends one write and asks no task or agent again, since each followed inbox is pushed its items' read state as it moves", async () => { listedWorkspace = ACME; const taskReads: string[] = []; startRead = (taskId) => { @@ -5521,17 +5455,16 @@ test("Mark all as read, once the service took it, reads every listed task and ev await settle(); const tasksBefore = taskReads.length; const agentsBefore = agentReads.length; + const followsBefore = main.asked.length; const marksBefore = readMarks.length; const options = page.querySelector('button[aria-label="View options"]'); act(() => options?.click()); act(() => menuItem("Mark all as read").click()); await settle(); assert.deepEqual(readMarks.slice(marksBefore), ["all:workspace-acme"]); - assert.deepEqual( - [...new Set(taskReads.slice(tasksBefore))].toSorted(), - [BILLING.id, INVITES.id].toSorted(), - ); - assert.deepEqual(agentReads.slice(agentsBefore), [SWEEP_AGENT]); + assert.deepEqual(main.asked.slice(followsBefore), [], "nothing is followed again"); + assert.deepEqual(taskReads.slice(tasksBefore), []); + assert.deepEqual(agentReads.slice(agentsBefore), []); }); test("a row swiped right marks its item: a long swipe marks an unread item read as it is let go, a short one leaves the row open on an Unread button for a read one, and neither opens the item", async () => { @@ -5641,14 +5574,6 @@ function answerWithNote(page: HTMLElement, note: string): void { } test("an answer leaves Needs you the moment it is sent, its plan's count dropping with it, and no stale read brings it back: not its plan's own held read, nor the list's once another plan's item is chosen, nor main's word before it caught up", async () => { - // The plan's first held read is the test's to land, and every one after it stays held. - const held = pending(); - let asked = false; - heldRead = (taskId) => { - if (taskId !== BILLING.id || asked) return new Promise(() => undefined); - asked = true; - return held.promise; - }; const { page } = mount(); await settle(); const { sent, answer } = gatedAnswers(); @@ -5673,8 +5598,10 @@ test("an answer leaves Needs you the moment it is sent, its plan's count droppin await act(async () => answer({ item: THRESHOLD_ANSWERED, taken: true })); await settle(); - // The plan's own held read lands as it stood before the answer, asked before it was taken. - await act(async () => held.settle({ items: [THRESHOLD, RETRY], cursor: "2" })); + // The plan's inbox is pushed as it stood before the answer, read before it was taken. + await act(async () => + main.push({ kind: INBOX_SCOPE_KIND.PLAN, id: BILLING.id }, { items: [THRESHOLD, RETRY] }), + ); await settle(); assert.equal(needsYouMark(rowNamed(page, "Alert at 80%?")), ""); @@ -5752,14 +5679,6 @@ test("on To do, answering the chosen item draws its row gray where it stood, and }); test("on To do, the chosen row is let go once the Inbox is left, so an item answered while it was hidden is gone when it is come back to", async () => { - // The invites plan's held read is the test's to land, as an answer from another Mac would. - let deliver: (outcome: ReadOutcome) => void = () => undefined; - heldRead = (taskId) => - taskId === INVITES.id - ? new Promise((resolve) => { - deliver = resolve; - }) - : new Promise(() => undefined); window.localStorage.setItem(SHOW_KEY, JSON.stringify("to-do")); window.localStorage.setItem(GROUPING_KEY, JSON.stringify("newest")); const { page } = mount(); @@ -5775,7 +5694,8 @@ test("on To do, the chosen row is let go once the Inbox is left, so an item answ settledAt: 6_000, updatedAt: 6_000, }; - act(() => deliver({ status: FAKE_ANSWER.DONE, value: { items: [answered], cursor: "2" } })); + // The invites plan's change is pushed, as an answer from another Mac would be. + main.push({ kind: INBOX_SCOPE_KIND.PLAN, id: INVITES.id }, { items: [answered] }); await settle(); act(() => setInboxOpen(true)); await settle(); @@ -6032,20 +5952,16 @@ test("grouped by status, what needs you stands under the attention mark, then wh test("an item marked read as it opened stays read once another plan's item is chosen, though the list's read of its plan, which a mark never moves, still holds it unread", async () => { inboxes.set(BILLING.id, [{ ...THRESHOLD, unread: true }, RETRY]); - // The plan's held read wakes on the mark, as the service wakes it, holding the item read. - const held = pending(); - let asked = false; - heldRead = (taskId) => { - if (taskId !== BILLING.id || asked) return new Promise(() => undefined); - asked = true; - return held.promise; - }; const { page } = mount(); await settle(); assert.deepEqual(readMarks, [`read:${THRESHOLD.id}`]); assert.equal(unreadOf(page, "Alert at 80%?"), false); + // The plan's inbox is pushed on the mark, as the host hears it, holding the item read. await act(async () => - held.settle({ items: [{ ...THRESHOLD, unread: false }, RETRY], cursor: "2" }), + main.push( + { kind: INBOX_SCOPE_KIND.PLAN, id: BILLING.id }, + { items: [{ ...THRESHOLD, unread: false }, RETRY] }, + ), ); await settle(); assert.equal(unreadOf(page, "Alert at 80%?"), false); @@ -6056,26 +5972,16 @@ test("an item marked read as it opened stays read once another plan's item is ch assert.equal(unreadOf(page, "Alert at 80%?"), false, "the list's older read brings no dot back"); }); -test("a mark another window makes later on the same item stands once the plan's held read holds it, over the window's own earlier mark", async () => { +test("a mark another window makes later on the same item stands once the plan's push holds it, over the window's own earlier mark", async () => { inboxes.set(BILLING.id, [{ ...THRESHOLD, unread: true }, RETRY]); - // The plan's held reads land as the test says: its own mark heard, then another window's. - const reads: PendingRead[] = [pending(), pending()]; - heldRead = (taskId) => - taskId === BILLING.id - ? (reads.shift()?.promise ?? new Promise(() => undefined)) - : new Promise(() => undefined); - const [own, other] = reads; - assert.ok(own !== undefined && other !== undefined); const { page } = mount(); await settle(); assert.equal(unreadOf(page, "Alert at 80%?"), false); - await act(async () => - own.settle({ items: [{ ...THRESHOLD, unread: false }, RETRY], cursor: "2" }), - ); + // The plan's inbox is pushed as the test says: its own mark heard, then another window's. + const billing = { kind: INBOX_SCOPE_KIND.PLAN, id: BILLING.id }; + await act(async () => main.push(billing, { items: [{ ...THRESHOLD, unread: false }, RETRY] })); await settle(); - await act(async () => - other.settle({ items: [{ ...THRESHOLD, unread: true }, RETRY], cursor: "3" }), - ); + await act(async () => main.push(billing, { items: [{ ...THRESHOLD, unread: true }, RETRY] })); await settle(); assert.equal(unreadOf(page, "Alert at 80%?"), true); }); @@ -6578,11 +6484,28 @@ test("opening the reader's own notice acknowledges it at its revision, once whil assert.deepEqual(answersOf(answered), [acknowledged, acknowledged]); }); -test("a notice shown while its plan's held read has failed reads Got it, since opening it acknowledges nothing then, and Got it answers it", async () => { - heldRead = () => answered(UNANSWERED_READ); +test("a notice shown while its plan's follow has failed reads Got it, since opening it acknowledges nothing then, and Got it answers it", async () => { const notice = noticeItem(); const { answered: sent } = noticeWrites(); - const pane = await mountThread(notice); + inboxes = new Map([ + [BILLING.id, [notice, RETRY]], + [INVITES.id, [EXPIRY]], + ]); + // The list's follow of the plan reads its items, and the item's own follow of it could not be read. + const unanswered = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; + let follows = 0; + startRead = (taskId) => { + if (taskId !== BILLING.id) return undefined; + follows += 1; + return follows === 1 + ? undefined + : Promise.resolve({ status: FAKE_ANSWER.DONE, value: unanswered }); + }; + const { page } = mount(); + await settle(); + act(() => rowNamed(page, notice.title).click()); + await settle(); + const pane = itemPane(page); assert.deepEqual(sent, [], "opening it answers nothing"); assert.equal(sendWords(pane), "Got it"); act(() => answerSend(pane).click()); diff --git a/packages/panel/src/desktop/desktop-tasks.test.ts b/packages/panel/src/desktop/desktop-tasks.test.ts index 419f7ec464..0b0deaa905 100644 --- a/packages/panel/src/desktop/desktop-tasks.test.ts +++ b/packages/panel/src/desktop/desktop-tasks.test.ts @@ -5,6 +5,7 @@ import { CODING_AGENT_CALL_FAILURE, type CodingAgentInboxAnswerParams, type CodingAgentInboxAnswerView, + INBOX_SCOPE_KIND, } from "@luke/features/coding-agent-view"; import { CHECK_SUMMARY, @@ -13,7 +14,6 @@ import { PULL_REQUEST_STATE, } from "@luke/features/coding-agent-wire"; import { - INBOX_CURSOR_START, INBOX_REPLY, INBOX_RESPONSE, INBOX_STATUS, @@ -45,6 +45,7 @@ import { onRegistry } from "../testing/copy-feedback.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; import { relayout } from "../testing/resize-observer.js"; import "../file-tree/index.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { codingAgentsControl, tasksControl, UNPINNED_SUMMARY } from "../testing/tasks-control.js"; import { DesktopTasks } from "./desktop-tasks.js"; @@ -241,10 +242,7 @@ test("a plan's Inbox tab whose read holds nothing says nothing needs you under t }, agents: codingAgentsControl({ agentIds: [agentId], - readInbox: ({ after }) => - after === INBOX_CURSOR_START - ? Promise.resolve({ items: [], cursor: "1" }) - : new Promise(() => undefined), + inboxFollower: followedInboxes(() => ({ items: [] })).follower, }), }), }), @@ -299,10 +297,10 @@ function approvalItem(id: string, title: string, at: number): InboxItem { async function narrowTaskInbox( items: readonly InboxItem[], width = 700, - /** What the read held open past the first answers, where a test settles it; else it stays open. */ + /** What is pushed after the first push, where a test settles it; else nothing is. */ later: Promise = new Promise(() => undefined), - /** What the first read answers, where a test holds it open; else the items given. */ - first: Promise = Promise.resolve({ items, cursor: "1" }), + /** What the first push says, where a test holds it back until it settles; else the items given, once followed. */ + first?: Promise, /** The account reading, none where a test does not say, and where its answers go. */ reader: Pick & Partial> = { me: undefined, @@ -316,6 +314,16 @@ async function narrowTaskInbox( removeEventListener: () => undefined, }), }); + // The inbox is pushed as `items` once followed, or as `first` says once it settles, and + // again as `later` says once it settles. + const main = followedInboxes(() => (first === undefined ? { items } : undefined)); + const pushed = (answer: CodingAgentInboxAnswerView) => + main.push( + { kind: INBOX_SCOPE_KIND.PLAN, id: TASK.id }, + "failure" in answer ? { failure: answer.failure } : { items: answer.items }, + ); + void first?.then(pushed); + void later.then(pushed); const container = document.body.appendChild(document.createElement("div")); const root = createRoot(container); act(() => @@ -343,12 +351,7 @@ async function narrowTaskInbox( ...(reader.answerInbox === undefined ? undefined : { answerInbox: reader.answerInbox }), - readInbox: ({ after }) => - after === INBOX_CURSOR_START - ? first - : after === "1" - ? later - : new Promise(() => undefined), + inboxFollower: main.follower, }), }), }), diff --git a/packages/panel/src/desktop/desktop-tasks.tsx b/packages/panel/src/desktop/desktop-tasks.tsx index 2dd35b281d..6fea714353 100644 --- a/packages/panel/src/desktop/desktop-tasks.tsx +++ b/packages/panel/src/desktop/desktop-tasks.tsx @@ -271,7 +271,6 @@ function TaskInboxTab({ act, workspaceId: task.workspaceId, // Note that a task's own Inbox offers no Mark all as read. - onMarkedAll: () => undefined, }); const closeItem = useInboxCloser({ act, onHeard: tasks.agents.onRetryList }); // Note that the task's read draws the window's own marks, answers, and messages over it already (`inbox-settling.ts`). diff --git a/packages/panel/src/desktop/inbox-link-chips.test.ts b/packages/panel/src/desktop/inbox-link-chips.test.ts index 1dbdb6ae96..0e34de27ed 100644 --- a/packages/panel/src/desktop/inbox-link-chips.test.ts +++ b/packages/panel/src/desktop/inbox-link-chips.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; import { PULL_REQUEST_STATE } from "@luke/features/coding-agent-wire"; import { INBOX_BLOCK, @@ -21,9 +21,17 @@ import { act, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, test } from "vitest"; import type { InboxChoice } from "../tasks/use-tasks-tab.js"; -import { type AnyAct, FAKE_ANSWER, type FakeActAnswer, fakeActs } from "../testing/app-bridge.js"; +import { + type AnyAct, + FAKE_ANSWER, + type FakeActAnswer, + fakeActPort, + fakeActPortCalls, + fakeActs, +} from "../testing/app-bridge.js"; import { signedInAs } from "../testing/app-state.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; import { tasksControl, waitingOf } from "../testing/tasks-control.js"; import { DesktopInbox } from "./desktop-inbox.js"; @@ -129,11 +137,12 @@ async function inbox(options: { ...signedInAs({ id: "user-sam", email: "sam@example.com", name: "Sam Okafor" }), tasks: { ...IDLE_TASKS_VIEW, tasks: [TASK], workspaces: options.workspaces ?? [ACME, GLOBEX] }, }; - const inboxOf = (items: readonly InboxItem[]): Promise => - Promise.resolve({ - status: FAKE_ANSWER.DONE, - value: { items, cursor: "1" } satisfies CodingAgentInboxAnswerView, - }); + // The host pushes the task's inbox, and the sweep agent's, as each is followed. + const host = followedInboxes(({ kind }) => + kind === INBOX_SCOPE_KIND.PLAN + ? { items: options.taskItems } + : { items: options.sweep === undefined ? [] : [options.sweep.item] }, + ); fakeActs({ onAppState: () => () => undefined, requestAppState: () => Promise.resolve(state), @@ -150,15 +159,6 @@ async function inbox(options: { : { ...pullRequest, state: PULL_REQUEST_STATE.MERGED }, }); } - case ACT_KIND.CODING_AGENTS_INBOX: - // A read past the cursor is held open while nothing changes, as the service holds it. - return request.payload.after === "0" - ? inboxOf(options.taskItems) - : new Promise(() => undefined); - case ACT_KIND.CODING_AGENTS_AGENT_INBOX: - return request.payload.after === "0" - ? inboxOf(options.sweep === undefined ? [] : [options.sweep.item]) - : new Promise(() => undefined); case ACT_KIND.CODING_AGENTS_LIST: return Promise.resolve({ status: FAKE_ANSWER.DONE, value: { agents: [] } }); default: @@ -169,6 +169,7 @@ async function inbox(options: { } }, }); + fakeActPort({ ...fakeActPortCalls(), ...host.procedures }); const sweep = options.sweep; // The Inbox's own choice is held as the plans tab holds it, so the item it stands chosen is drawn. function Page() { diff --git a/packages/panel/src/desktop/inbox-side-panel.test.ts b/packages/panel/src/desktop/inbox-side-panel.test.ts index a8bca7dc3f..e4e5615bbc 100644 --- a/packages/panel/src/desktop/inbox-side-panel.test.ts +++ b/packages/panel/src/desktop/inbox-side-panel.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import type { BrowserFocus, BrowserKey } from "@luke/features/browser-acts"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; import { CHECK_SUMMARY, CODING_AGENT_STATUS, @@ -34,10 +33,18 @@ import { useInboxTaskAgents } from "../tasks/use-inbox-task-agents.js"; import { OpenLinkProvider } from "../tasks/use-open-link.js"; import { type SidePanelControl, useSidePanel } from "../tasks/use-side-panel.js"; import type { InboxChoice } from "../tasks/use-tasks-tab.js"; -import { type AnyAct, FAKE_ANSWER, type FakeActAnswer, fakeActs } from "../testing/app-bridge.js"; +import { + type AnyAct, + FAKE_ANSWER, + type FakeActAnswer, + fakeActPort, + fakeActPortCalls, + fakeActs, +} from "../testing/app-bridge.js"; import { fakeAppPlatform, QUIET_PANEL } from "../testing/app-platform.js"; import { handState } from "../testing/app-state.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { markdownHtmlParserLanded } from "../testing/markdown.js"; import { tasksControl, waitingOf } from "../testing/tasks-control.js"; import { DesktopInbox } from "./desktop-inbox.js"; @@ -145,6 +152,9 @@ afterEach(() => { /** Every read mark the Inbox sent, in order. */ let readMarks: string[] = []; +/** The host's inbox streams for the window, the task's inbox pushed as holding the one item. */ +const follows = followedInboxes(() => ({ items: [ITEM] })); + function answer(request: AnyAct): Promise { switch (request.kind) { case ACT_KIND.CODING_AGENTS_INBOX_READ_STATE: @@ -153,14 +163,6 @@ function answer(request: AnyAct): Promise { status: FAKE_ANSWER.DONE, value: { id: request.payload.itemId, unread: request.payload.unread }, }); - case ACT_KIND.CODING_AGENTS_INBOX: - // A read past the cursor is held open while nothing changes, as the service holds it. - return request.payload.after === "0" - ? Promise.resolve({ - status: FAKE_ANSWER.DONE, - value: { items: [ITEM], cursor: "1" } satisfies CodingAgentInboxAnswerView, - }) - : new Promise(() => undefined); case ACT_KIND.CODING_AGENTS_LIST: return Promise.resolve({ status: FAKE_ANSWER.DONE, @@ -228,6 +230,7 @@ async function inbox( onBrowserOpened: () => () => undefined, onBrowserClosed: () => () => undefined, }); + fakeActPort({ ...fakeActPortCalls(), ...follows.procedures }); handState(experiments); function Page() { const [chosen, setChosen] = useState(undefined); diff --git a/packages/panel/src/desktop/person-page.test.ts b/packages/panel/src/desktop/person-page.test.ts index 6720cfd524..cbddbd9e33 100644 --- a/packages/panel/src/desktop/person-page.test.ts +++ b/packages/panel/src/desktop/person-page.test.ts @@ -9,7 +9,7 @@ import { type AutomationScope, type AutomationSummary, } from "@luke/features/automation-wire"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; +import { CODING_AGENT_CALL_FAILURE, INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS } from "@luke/features/coding-agent-wire"; import { INBOX_RESPONSE, @@ -31,6 +31,7 @@ import { fakeActPort, fakeAppBridge, refusedOnPort } from "../testing/app-bridge import { copyingInto, fakeAppPlatform, QUIET_PANEL } from "../testing/app-platform.js"; import { handState } from "../testing/app-state.js"; import { onRegistry } from "../testing/copy-feedback.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { tasksControl, waitingOf } from "../testing/tasks-control.js"; import { PersonPage } from "./person-page.js"; @@ -217,18 +218,16 @@ beforeEach(() => { fakeAppPlatform({ ...QUIET_PANEL, ...copyingInto(copied) }); window.localStorage.clear(); fakeAppBridge({}); + // The host pushes each task's inbox, and the sweep agent's, as it is followed, unless the test holds or refuses them. + const host = followedInboxes(({ kind, id }) => { + if (inboxHeld) return undefined; + if (kind === INBOX_SCOPE_KIND.AGENT) return { items: [SWEEP_ITEM] }; + return inboxRefused + ? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } + : { items: INBOXES.get(id) ?? [] }; + }); fakeActPort({ - [ACT_KIND.CODING_AGENTS_AGENT_INBOX]: () => { - if (inboxHeld) return new Promise(() => undefined); - const value: CodingAgentInboxAnswerView = { items: [SWEEP_ITEM], cursor: "1" }; - return Promise.resolve(value); - }, - [ACT_KIND.CODING_AGENTS_INBOX]: ({ taskId }) => { - if (inboxHeld) return new Promise(() => undefined); - if (inboxRefused) return Promise.reject(refusedOnPort("Not here.")); - const value: CodingAgentInboxAnswerView = { items: INBOXES.get(taskId) ?? [], cursor: "1" }; - return Promise.resolve(value); - }, + ...host.procedures, [ACT_KIND.AUTOMATIONS_LIST]: () => { if (automationsHeld) return new Promise(() => undefined); if (automationsRefused) return Promise.reject(refusedOnPort("Not here.")); diff --git a/packages/panel/src/read-failure.test.ts b/packages/panel/src/read-failure.test.ts index 7703bce327..6db5876225 100644 --- a/packages/panel/src/read-failure.test.ts +++ b/packages/panel/src/read-failure.test.ts @@ -6,7 +6,6 @@ import { CODING_AGENT_CALL_FAILURE, type CodingAgentChangesAnswerView, } from "@luke/features/coding-agent-view"; -import { INBOX_CURSOR_START } from "@luke/features/inbox-wire"; import type { TaskSummary } from "@luke/features/task-wire"; import { ACT_KIND } from "@luke/host/acts"; import { UPDATE_STATUS } from "@luke/settings/update"; @@ -24,6 +23,7 @@ import { fakeActPort, fakeAppBridge } from "./testing/app-bridge.js"; import { fakeAppPlatform, QUIET_PANEL } from "./testing/app-platform.js"; import { ChangesTabReading } from "./testing/changes-tab.js"; import { onRegistry } from "./testing/copy-feedback.js"; +import { followedInboxes } from "./testing/followed-inboxes.js"; import { codingAgentsControl, tasksControl } from "./testing/tasks-control.js"; /** @@ -49,6 +49,9 @@ const TASK: TaskSummary = { /** Whether the service answers a read, rather than failing it, as a Retry finds it once it is back. */ let answering = false; +/** The host's inbox streams: a followed inbox could not be read until the service answers again, then holds nothing. */ +const host = followedInboxes(() => (answering ? { items: [] } : UNANSWERED)); + beforeEach(() => { fakeAppPlatform(QUIET_PANEL); answering = false; @@ -60,11 +63,8 @@ beforeEach(() => { }); fakeAppBridge({}); fakeActPort({ - // A read past the cursor is held open while nothing changes, as the service holds it; every read fails until the service answers again, then answers with nothing in it. - [ACT_KIND.CODING_AGENTS_INBOX]: ({ after }) => - after !== INBOX_CURSOR_START - ? new Promise(() => undefined) - : Promise.resolve(answering ? { items: [], cursor: "1" } : UNANSWERED), + // Every followed inbox could not be read until the service answers again, then holds nothing. + ...host.procedures, [ACT_KIND.CODING_AGENTS_LIST]: () => Promise.resolve({ agents: [] }), [ACT_KIND.PROVIDER_KEYS_READ]: () => Promise.resolve(answering ? { keys: [] } : UNANSWERED), }); diff --git a/packages/panel/src/tasks/use-coding-agents.ts b/packages/panel/src/tasks/use-coding-agents.ts index bd8a34a0a1..7ec959bf9b 100644 --- a/packages/panel/src/tasks/use-coding-agents.ts +++ b/packages/panel/src/tasks/use-coding-agents.ts @@ -78,12 +78,8 @@ import { type TranscriptReader, windowTranscriptFollower, } from "./use-agent-transcript.js"; -import type { - InboxAnswerer, - InboxImageReader, - InboxMessenger, - InboxReader, -} from "./use-task-inbox.js"; +import { type InboxFollower, windowInboxFollower } from "./use-followed-inboxes.js"; +import type { InboxAnswerer, InboxImageReader, InboxMessenger } from "./use-task-inbox.js"; /** A pull request's chapters the port refused to answer, as the tab hears any answer that never came. */ const CHAPTERS_UNANSWERED: PullRequestChaptersAnswerView = { @@ -379,8 +375,8 @@ export interface CodingAgentsControl { keywords: readonly string[]; /** The skills of the open plan's repository a `/` in an agent's box offers, read once for the plan (`repository-skills.ts`), or of the agent's own where no plan is open; nothing while neither names a repository. */ readSkills: SkillSource | undefined; - /** One read of a task's inbox past a cursor, as the Inbox tab's loop asks it. */ - readInbox: InboxReader; + /** How a task's or an agent's inbox is followed, and heard pushed, as the Inbox tab draws it. */ + inboxFollower: InboxFollower; /** One answer to an inbox item, as a card sends it. */ answerInbox: InboxAnswerer; /** One message on an inbox item's thread, as its box sends it. */ @@ -1402,10 +1398,7 @@ export function useCodingAgents(input: { [act], ); - const readInbox = useCallback( - (params) => act(ACT_KIND.CODING_AGENTS_INBOX, params), - [act], - ); + const inboxFollower = windowInboxFollower; const answerInbox = useCallback( (params) => act(ACT_KIND.CODING_AGENTS_INBOX_ANSWER, params), @@ -1507,7 +1500,7 @@ export function useCodingAgents(input: { readPullRequestViewed, keywords: input.keywords ?? NO_KEYWORDS, readSkills, - readInbox, + inboxFollower, answerInbox, messageInbox, readInboxImage, diff --git a/packages/panel/src/tasks/use-followed-inboxes.ts b/packages/panel/src/tasks/use-followed-inboxes.ts new file mode 100644 index 0000000000..d8022221fe --- /dev/null +++ b/packages/panel/src/tasks/use-followed-inboxes.ts @@ -0,0 +1,137 @@ +import { + CODING_AGENT_CALL_FAILURE, + type CodingAgentInboxChanged, + type CodingAgentInboxScope, +} from "@luke/features/coding-agent-view"; +import type { InboxItem } from "@luke/features/inbox-wire"; +import { AGENT_INBOX_ACT_KIND } from "@luke/host/contract"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { actPort } from "../app-bridge.js"; + +/** + * use-followed-inboxes.ts -- the inboxes of the plans, or of the agents with no plan, a surface draws, followed while it draws them and pushed to the window as each changes. + * + * The window holds one stream open on its act port per plan or agent it + * draws (`codingAgents.inboxFollow`), and closes it as it stops; the host + * follows each inbox while any stream of it stands and sends every change + * down each, so nothing here asks again, holds a cursor, or keeps a loop. + * Each inbox's items are held as they were last pushed, and kept once it is + * let go, since nothing newer is on its way. One whose last word was that + * it could not be read is let go, and followed again on Try again; its + * items as last pushed stand meanwhile. + */ + +type ScopeKind = CodingAgentInboxScope["kind"]; + +/** A followed inbox as the window hears it: its items whole, or why there are none. */ +type Heard = CodingAgentInboxChanged["inbox"]; + +/** How the window follows an inbox: hearing each change until the stop it answers is called. */ +export interface InboxFollower { + readonly follow: (scope: CodingAgentInboxScope, hear: (inbox: Heard) => void) => () => void; +} + +const UNANSWERED: Heard = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; + +/** + * The window's own follower: one stream on its act port per follow, closed + * by the stop it answers. A stream that could not open, or broke, is heard + * as unanswered, unless it was stopped first. + */ +export const windowInboxFollower: InboxFollower = { + follow: (scope, hear) => { + let stopped = false; + let stream: AsyncIteratorObject | undefined; + void (async () => { + stream = await actPort()[AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_FOLLOW](scope); + if (stopped) { + await stream.return?.(undefined); + return; + } + for await (const inbox of stream) hear(inbox); + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- What ended a stream is unknown until this reads it. + })().catch((_error: unknown) => { + if (!stopped) hear(UNANSWERED); + }); + return () => { + stopped = true; + void stream?.return?.(undefined); + }; + }, +}; + +/** The inboxes as last pushed, and which could not be read. */ +interface FollowedInboxes { + /** Each inbox's items as last pushed, by plan or agent; one never pushed is absent. */ + readonly items: ReadonlyMap; + /** The inboxes whose last word was that they could not be read, followed no more until Try again. */ + readonly failed: ReadonlySet; + /** Follows again every inbox that could not be read. */ + readonly onRetry: () => void; +} + +/** The inbox pushed, folded into what is held: its items, or its failure. */ +function heard( + held: { items: ReadonlyMap; failed: ReadonlySet }, + id: string, + inbox: Heard, +) { + if ("failure" in inbox) return { items: held.items, failed: new Set(held.failed).add(id) }; + const failed = new Set(held.failed); + failed.delete(id); + return { items: new Map(held.items).set(id, inbox.items), failed }; +} + +/** Whether two sorted lists name the same inboxes. */ +function sameIds(one: readonly string[], other: readonly string[]): boolean { + return one.length === other.length && one.every((id, at) => id === other[at]); +} + +export function useFollowedInboxes( + kind: ScopeKind, + ids: readonly string[], + follower: InboxFollower, +): FollowedInboxes { + // Note that the follower is read through a ref, so a surface handing a fresh one each render follows nothing anew. + const latest = useRef(follower); + latest.current = follower; + const [held, setHeld] = useState<{ + items: ReadonlyMap; + failed: ReadonlySet; + }>({ items: new Map(), failed: new Set() }); + // The stop of each inbox followed now, by its id. + const following = useRef(new Map void>()); + + // Note that the wanted ids are kept as they were while they name the same inboxes, so a list + // drawn again, or in another order, follows nothing anew. + const wantedNow = [...new Set(ids)].filter((id) => !held.failed.has(id)).toSorted(); + const wantedRef = useRef([]); + if (!sameIds(wantedRef.current, wantedNow)) wantedRef.current = wantedNow; + const wanted = wantedRef.current; + useEffect(() => { + const now = new Set(wanted); + for (const id of now) { + if (following.current.has(id)) continue; + const stop = latest.current.follow({ kind, id }, (inbox) => + setHeld((was) => heard(was, id, inbox)), + ); + following.current.set(id, stop); + } + for (const [id, stop] of following.current) { + if (now.has(id)) continue; + following.current.delete(id); + stop(); + } + }, [kind, wanted]); + // Every inbox still followed is let go as the surface goes. + useEffect( + () => () => { + for (const stop of following.current.values()) stop(); + following.current.clear(); + }, + [kind], + ); + + const onRetry = useCallback(() => setHeld((was) => ({ ...was, failed: new Set() })), []); + return { items: held.items, failed: held.failed, onRetry }; +} diff --git a/packages/panel/src/tasks/use-inbox-items.test.ts b/packages/panel/src/tasks/use-inbox-items.test.ts index 9b3e8e7528..846ce902ea 100644 --- a/packages/panel/src/tasks/use-inbox-items.test.ts +++ b/packages/panel/src/tasks/use-inbox-items.test.ts @@ -1,19 +1,22 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; -import { INBOX_CURSOR_START } from "@luke/features/inbox-wire"; +import { INBOX_RESPONSE, INBOX_STATUS, type InboxItem } from "@luke/features/inbox-wire"; import type { AppInboxTaskHolding } from "@luke/host/app-state"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; +import { fakeActPort, fakeAppBridge } from "../testing/app-bridge.js"; +import { followedInboxes } from "../testing/followed-inboxes.js"; import { type InboxItemsControl, useInboxItems } from "./use-inbox-items.js"; /** - * use-inbox-items.test.ts -- the window's Inbox reads each listed task's - * items, and says a read failed only of a task it still lists, so a - * workspace switched to is not told of another's failure. + * use-inbox-items.test.ts -- the window's Inbox follows each listed task's + * items while it lists it, holds each push, lets a task go as it leaves the + * list, says a task could not be read only of a task it still lists, so a + * workspace switched to is not told of another's failure, and follows a + * failed task again on Try again. */ let root: Root | undefined; @@ -25,84 +28,109 @@ afterEach(() => { async function settle(): Promise { await act(async () => { - for (let tick = 0; tick < 8; tick += 1) await Promise.resolve(); + for (let tick = 0; tick < 40; tick += 1) await Promise.resolve(); }); } -const holding = (taskId: string): AppInboxTaskHolding => ({ - taskId: taskId, - items: 1, +const holding = (taskId: string): AppInboxTaskHolding => ({ taskId, items: 1, updatedAt: 1 }); + +const ITEM: InboxItem = { + id: "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b", + agentId: "8c1a6a4f-3d2e-4d8b-8b66-6f4c7a2e3b21", + key: "deploy-window", + revision: 1, + version: 1, + title: "Deploy tonight?", + blocks: [], + response: { type: INBOX_RESPONSE.APPROVAL }, + needsReply: true, + status: INBOX_STATUS.OPEN, + postedAt: 1, updatedAt: 1, -}); +}; -test("a read that failed for a plan another workspace listed says nothing once the list holds only plans that read", async () => { - // The service refuses Personal's task and answers Acme's with nothing waiting. - const readInbox = ({ taskId }: { taskId: string }): Promise => - Promise.resolve( - taskId === "personal-plan" - ? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } - : { items: [], cursor: INBOX_CURSOR_START }, - ); +/** The host's inbox streams, standing in for the act port. */ +function bridge(main: ReturnType) { + fakeAppBridge({}); + fakeActPort(main.procedures); +} + +function mount(holdings: readonly AppInboxTaskHolding[]) { let control: InboxItemsControl | undefined; - function Probe({ holdings }: { holdings: readonly AppInboxTaskHolding[] }) { - control = useInboxItems({ holdings, readInbox }); + function Probe({ listed }: { listed: readonly AppInboxTaskHolding[] }) { + control = useInboxItems({ holdings: listed }); return null; } root = createRoot(document.createElement("div")); - act(() => root?.render(createElement(Probe, { holdings: [holding("personal-plan")] }))); + act(() => root?.render(createElement(Probe, { listed: holdings }))); + return { + control: () => control, + list: (listed: readonly AppInboxTaskHolding[]) => + act(() => root?.render(createElement(Probe, { listed }))), + }; +} + +test("each listed plan is pending until its inbox is pushed, every push replaces its items, and a plan let go of keeps its last ones", async () => { + const main = followedInboxes(() => ({ items: [ITEM] })); + bridge(main); + const { control, list } = mount([holding("acme-plan")]); + const before = control()?.pending; + await settle(); + const pushed = control()?.pending; + const answered = { ...ITEM, revision: 2, status: INBOX_STATUS.ANSWERED }; + main.push({ kind: "plan", id: "acme-plan" }, { items: [answered] }); await settle(); - const before = control?.failed; - act(() => root?.render(createElement(Probe, { holdings: [holding("acme-plan")] }))); + const after = control()?.items.get("acme-plan"); + list([]); await settle(); - assert.equal(before, true); - assert.equal(control?.failed, false); + assert.deepEqual([before, pushed], [true, false]); + assert.deepEqual(after, [answered]); + assert.equal(main.isFollowed({ kind: "plan", id: "acme-plan" }), false); + assert.deepEqual(control()?.items.get("acme-plan"), [answered]); + assert.deepEqual(main.asked, ["plan:acme-plan"]); }); -test("a plan that changed since its items were read is pending until a read of it answers, its old items standing meanwhile", async () => { - const answers: ((answer: CodingAgentInboxAnswerView) => void)[] = []; - const readInbox = (): Promise => - new Promise((resolve) => answers.push(resolve)); - let control: InboxItemsControl | undefined; - function Probe({ holdings }: { holdings: readonly AppInboxTaskHolding[] }) { - control = useInboxItems({ holdings, readInbox }); - return null; - } - root = createRoot(document.createElement("div")); - act(() => root?.render(createElement(Probe, { holdings: [holding("acme-plan")] }))); - const unread = control?.pending; - await act(async () => answers[0]?.({ items: [], cursor: INBOX_CURSOR_START })); +test("a plan main says changed later than its last push is pending until a push catches up, its items standing meanwhile", async () => { + const main = followedInboxes(() => ({ items: [ITEM] })); + bridge(main); + const { control, list } = mount([holding("acme-plan")]); await settle(); - const read = control?.pending; - act(() => - root?.render(createElement(Probe, { holdings: [{ ...holding("acme-plan"), updatedAt: 2 }] })), - ); + const pushed = control()?.pending; + list([{ ...holding("acme-plan"), updatedAt: 2 }]); + const behind = [control()?.pending, control()?.current.has("acme-plan")]; + main.push({ kind: "plan", id: "acme-plan" }, { items: [{ ...ITEM, updatedAt: 2 }] }); await settle(); - assert.deepEqual([unread, read, control?.pending], [true, false, true]); - assert.deepEqual(control?.items.get("acme-plan"), []); + assert.deepEqual([pushed, ...behind], [false, true, false]); + assert.equal(control()?.pending, false); + assert.deepEqual(control()?.current, new Set(["acme-plan"])); }); -test("asked to read again, every listed plan is read anew though main says none changed, its last items standing, and not pending, until the new read lands", async () => { - const answers: ((answer: CodingAgentInboxAnswerView) => void)[] = []; - const readInbox = (): Promise => - new Promise((resolve) => answers.push(resolve)); - let control: InboxItemsControl | undefined; - function Probe({ holdings }: { holdings: readonly AppInboxTaskHolding[] }) { - control = useInboxItems({ holdings, readInbox }); - return null; - } - root = createRoot(document.createElement("div")); - act(() => root?.render(createElement(Probe, { holdings: [holding("acme-plan")] }))); - await act(async () => answers[0]?.({ items: [], cursor: INBOX_CURSOR_START })); +test("a plan that could not be read says so only while it is listed, is let go, and is followed again on Try again", async () => { + // The service refuses Personal's task, until it is asked again, and answers Acme's with nothing waiting. + let refuse = true; + const main = followedInboxes(({ id }) => + id === "personal-plan" && refuse + ? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } + : { items: [] }, + ); + bridge(main); + const { control, list } = mount([holding("personal-plan")]); await settle(); - assert.equal(answers.length, 1); - - act(() => control?.onReread()); + const failed = control()?.failed; + const followed = main.isFollowed({ kind: "plan", id: "personal-plan" }); + list([holding("acme-plan")]); await settle(); - assert.equal(answers.length, 2, "the plan is read again"); - assert.deepEqual([control?.pending, control?.items.get("acme-plan")], [false, []]); - await act(async () => answers[1]?.({ items: [], cursor: "2" })); + const elsewhere = control()?.failed; + + list([holding("personal-plan")]); + refuse = false; + act(() => control()?.onRetry()); await settle(); - assert.equal(answers.length, 2, "and once"); + + assert.deepEqual([failed, followed, elsewhere], [true, false, false]); + assert.equal(control()?.failed, false); + assert.deepEqual(control()?.items.get("personal-plan"), []); + assert.deepEqual(main.asked, ["plan:personal-plan", "plan:acme-plan", "plan:personal-plan"]); }); diff --git a/packages/panel/src/tasks/use-inbox-items.ts b/packages/panel/src/tasks/use-inbox-items.ts index 3dd4243c7a..02095aa765 100644 --- a/packages/panel/src/tasks/use-inbox-items.ts +++ b/packages/panel/src/tasks/use-inbox-items.ts @@ -1,147 +1,74 @@ -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentInboxAnswerView, -} from "@luke/features/coding-agent-view"; -import { INBOX_CURSOR_START, type InboxItem } from "@luke/features/inbox-wire"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; +import type { InboxItem } from "@luke/features/inbox-wire"; import type { AppInboxTaskHolding } from "@luke/host/app-state"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; import { useSettledReads } from "./inbox-settling.js"; -import type { InboxReader } from "./use-task-inbox.js"; +import { useFollowedInboxes, windowInboxFollower } from "./use-followed-inboxes.js"; /** - * use-inbox-items.ts -- every listed task's inbox items for the window's Inbox, each task read once and again only when main says it changed. + * use-inbox-items.ts -- every listed task's inbox items for the window's Inbox, each task followed while it is listed and pushed as it changes. * * The window's Inbox lists the items of every task holding one, settled as * well as waiting, and main's read of the account carries only what waits, - * in brief. So each task main lists is read whole here, from the start, which - * the service answers at once, and read again only when main's list says - * the task's latest item moved, rather than holding a read open on every - * task: main's own read is already held open on the account, and is what - * hears a change. The task whose item is chosen holds its own read open - * besides (`use-task-inbox.ts`), which answers and is drawn in this one's - * place, and the window's own answers stand over every task's read until - * it holds them (`inbox-settling.ts`), so a stale read never brings an - * answered item back. An answer to a read a newer one - * overtook is let go, items and failure alike. A read that did not answer leaves the - * task as it was last read, or unread, and says so, with Try again; a - * later change of the task's asks again of its own accord. + * in brief. So each task main lists is followed whole here + * (`use-followed-inboxes.ts`), and its items are pushed to the window as + * they change rather than read again. The task whose item is chosen is + * followed by its own control besides (`use-task-inbox.ts`), which answers + * and is drawn in this one's place, and the window's own answers stand over + * every task's push until it holds them (`inbox-settling.ts`), so a stale + * push never brings an answered item back. A task that could not be read + * leaves its items as they were last pushed, or none, and says so, with + * Try again; one main lists no more keeps its items as last pushed. */ -/** One task's items as last read, and the moment main said its latest item changed when they were asked for. */ -interface Read { - readonly at: number; - /** The re-read it answered, so a read every task is asked again for (`onReread`) is asked once more. */ - readonly round: number; - readonly items: readonly InboxItem[]; -} - -const UNANSWERED = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; - /** What the window's Inbox lists from. */ export interface InboxItemsControl { - /** Each task's items as last read, by task; a task not yet read is absent. */ + /** Each task's items as last pushed, by task; a task not yet pushed is absent. */ readonly items: ReadonlyMap; - /** Whether a task's last read did not answer, so the list offers to try again. */ + /** Whether a listed task could not be read, so the list offers to try again. */ readonly failed: boolean; - /** Whether a listed plan has changed since its items were last read, and no read of it has failed: its items may be older than it. */ + /** Whether a listed task has not been pushed as main says it now stands, and was not refused: its items are on their way. */ readonly pending: boolean; - /** The plans whose items are read as of main's latest word of them, or read and listed no more, so an item they do not hold is not theirs. */ + /** The tasks pushed as main says they now stand, or pushed and listed no more, which stand for them whole, so an item they do not hold is not theirs. */ readonly current: ReadonlySet; readonly onRetry: () => void; - /** Reads every listed task again, as of now, though main says none changed: what a write main does not hear of, every item marked read, needs. */ - readonly onReread: () => void; +} + +/** When the latest of the items changed; nothing for none. */ +function newest(items: readonly InboxItem[]): number { + return items.reduce((latest, item) => Math.max(latest, item.updatedAt), Number.NEGATIVE_INFINITY); } export function useInboxItems(input: { /** Every task the Inbox lists, as main last read them. */ holdings: readonly AppInboxTaskHolding[]; - readInbox: InboxReader; }): InboxItemsControl { - const { holdings, readInbox } = input; - const [reads, setReads] = useState>(new Map()); - const [failed, setFailed] = useState>(new Set()); - const [attempt, setAttempt] = useState(0); - // How many times every task was asked to be read again, each read answering for the round it was asked in. - const round = useRef(0); - // The read out for each task, by the moment it asks for, so a task is asked once per change. - const asking = useRef(new Map()); - // The latest moment each task was asked for, so an answer to an earlier ask, landing last, is let go. - const newest = useRef(new Map()); - const live = useRef(true); - useEffect(() => { - live.current = true; - return () => { - live.current = false; - }; - }, []); - const latest = useRef({ readInbox, reads }); - latest.current = { readInbox, reads }; - - // A retry's press asks again of the tasks whose read failed, the ones not yet read. - useEffect(() => { - for (const { taskId, updatedAt } of holdings) { - const read = latest.current.reads.get(taskId); - if (read?.at === updatedAt && read.round === round.current) continue; - if (asking.current.get(taskId) === updatedAt) continue; - asking.current.set(taskId, updatedAt); - const asked = round.current; - newest.current.set(taskId, Math.max(newest.current.get(taskId) ?? updatedAt, updatedAt)); - latest.current - .readInbox({ taskId: taskId, after: INBOX_CURSOR_START }) - .catch((): CodingAgentInboxAnswerView => UNANSWERED) - .then((answer) => { - if (asking.current.get(taskId) === updatedAt) asking.current.delete(taskId); - // Note that a superseded answer moves neither the items nor the failure, because the newer ask's answer says both. - if (!live.current || updatedAt < (newest.current.get(taskId) ?? updatedAt)) return; - const failure = "failure" in answer; - setFailed((was) => { - if (was.has(taskId) === failure) return was; - const next = new Set(was); - if (failure) next.add(taskId); - else next.delete(taskId); - return next; - }); - if (failure) return; - setReads((was) => - new Map(was).set(taskId, { at: updatedAt, round: asked, items: answer.items }), - ); - }); - } - }, [holdings, attempt]); - - const onRetry = useCallback(() => { - setFailed(new Set()); - setAttempt((count) => count + 1); - }, []); - // Note that each task's last read stays drawn until the new one lands. - const onReread = useCallback(() => { - round.current += 1; - asking.current.clear(); - setAttempt((count) => count + 1); - }, []); - const readItems = useMemo( - () => new Map([...reads].map(([taskId, read]) => [taskId, read.items])), - [reads], - ); - // Note that the window's own answers and messages stand over a read until it holds them, so a stale read never brings an answered item back. - const items = useSettledReads(readItems); - // Note that only a task still listed fails the list, because one another workspace listed is not drawn. - const pending = holdings.some( - ({ taskId, updatedAt }) => reads.get(taskId)?.at !== updatedAt && !failed.has(taskId), - ); - // Note that a plan main lists no more has no newer read on its way, so its last read stands. - const current = new Set( - [...reads].flatMap(([taskId, read]) => { - const holding = holdings.find((each) => each.taskId === taskId); - return holding === undefined || holding.updatedAt === read.at ? [taskId] : []; - }), + const { holdings } = input; + const ids = useMemo(() => holdings.map((holding) => holding.taskId), [holdings]); + const { + items: pushed, + failed, + onRetry, + } = useFollowedInboxes(INBOX_SCOPE_KIND.PLAN, ids, windowInboxFollower); + // Note that a task main says changed later than anything pushed of it is not current yet, because + // its push may land a moment after main's own word of the change. + const current = useMemo( + () => + new Set( + [...pushed].flatMap(([taskId, held]) => { + const holding = holdings.find((each) => each.taskId === taskId); + return holding === undefined || newest(held) >= holding.updatedAt ? [taskId] : []; + }), + ), + [pushed, holdings], ); + // Note that the window's own answers and messages stand over a push until it holds them, so a stale push never brings an answered item back. + const items = useSettledReads(pushed); return { items, - failed: holdings.some((holding) => failed.has(holding.taskId)), - pending, + failed: ids.some((taskId) => failed.has(taskId)), + pending: ids.some((taskId) => !current.has(taskId) && !failed.has(taskId)), current, onRetry, - onReread, }; } diff --git a/packages/panel/src/tasks/use-inbox-listing.ts b/packages/panel/src/tasks/use-inbox-listing.ts index ec97c21d61..5914158e90 100644 --- a/packages/panel/src/tasks/use-inbox-listing.ts +++ b/packages/panel/src/tasks/use-inbox-listing.ts @@ -1,9 +1,5 @@ -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentInboxParams, -} from "@luke/features/coding-agent-view"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; import type { InboxItem } from "@luke/features/inbox-wire"; -import { ACT_KIND } from "@luke/host/acts"; import type { AppInboxTaskHolding } from "@luke/host/app-state"; import { useCallback, useEffect, useMemo } from "react"; import { useAct } from "../act.js"; @@ -111,10 +107,6 @@ export function useInboxListing( (automationId: string) => onOpenAutomations({ kind: AUTOMATIONS_PAGE.EDIT, automationId }), [onOpenAutomations], ); - const readInbox = useCallback( - (params: CodingAgentInboxParams) => act(ACT_KIND.CODING_AGENTS_INBOX, params), - [act], - ); const { inboxTasks, tasks: listedTasks, archived: listedArchived } = tasks.listed; const holdings = useMemo( () => (enabled ? listedHoldings(inboxTasks, listedTasks) : NO_HOLDINGS), @@ -124,12 +116,11 @@ export function useInboxListing( () => (enabled ? listedHoldings(inboxTasks, listedArchived) : NO_HOLDINGS), [enabled, inboxTasks, listedArchived], ); - const read = useInboxItems({ holdings, readInbox }); + const read = useInboxItems({ holdings }); // Note that the archived tasks' items are read only while Archived is shown, since nothing in them waits on anyone. const showArchived = choices.show === INBOX_SHOW.ARCHIVED; const archivedRead = useInboxItems({ holdings: showArchived ? archivedHoldings : NO_HOLDINGS, - readInbox, }); const waitingTaskless = enabled ? tasks.inboxTaskless : NO_TASKLESS; const tasklessReads = useTasklessInboxItems(waitingTaskless); @@ -161,17 +152,10 @@ export function useInboxListing( onStarted: () => undefined, me: tasks.me, }); - const readAgentInbox = useCallback( - (after: string) => - chosenAgentId === undefined - ? Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const) - : act(ACT_KIND.CODING_AGENTS_AGENT_INBOX, { agentId: chosenAgentId, after }), - [act, chosenAgentId], - ); // Note that an agent with no task holds its own read, by its id, as a task's is held by the task's. const live = useTaskInbox({ taskId: taskId ?? chosenAgentId, - ...(chosenAgentId === undefined ? undefined : { reader: readAgentInbox }), + kind: chosenAgentId === undefined ? INBOX_SCOPE_KIND.PLAN : INBOX_SCOPE_KIND.AGENT, shown: tasks.shown, waiting: tasks.inbox.some((each) => each.taskId === taskId) || @@ -213,17 +197,8 @@ export function useInboxListing( }, ] : []; - const { onReread: rereadTasks } = read; - const { onReread: rereadTaskless } = taskless; - // Note that every item marked read moves nothing main hears of, so the Inbox reads its items again once the service took it. - const marks = useInboxReadMarks({ - act, - workspaceId: tasks.workspace?.id, - onMarkedAll: () => { - rereadTasks(); - rereadTaskless(); - }, - }); + // Note that every item marked read is pushed with each followed inbox, its marker among its rows, so nothing is read again. + const marks = useInboxReadMarks({ act, workspaceId: tasks.workspace?.id }); // Note that every read here draws the window's own marks, answers, and messages over it already (`inbox-settling.ts`). // The archived tasks' items stand under Archived alone, which counts what main says each holds whether or not it is read yet. const standing: readonly InboxGroup[] = [ diff --git a/packages/panel/src/tasks/use-inbox-read-marks.test.ts b/packages/panel/src/tasks/use-inbox-read-marks.test.ts index 8f37476663..6ffb61f4d6 100644 --- a/packages/panel/src/tasks/use-inbox-read-marks.test.ts +++ b/packages/panel/src/tasks/use-inbox-read-marks.test.ts @@ -78,7 +78,6 @@ afterEach(() => { /** The hook over the reads given, every write it sends held until the test answers it; and how to give it newer reads. */ function marks(first: readonly InboxItem[]) { const sent: Sent[] = []; - let rereads = 0; /** One write held until the test answers it, with the value it answers when it does not fail. */ const sending = (words: string, value: Value) => new Promise((resolve) => { @@ -106,9 +105,6 @@ function marks(first: readonly InboxItem[]) { control = useInboxReadMarks({ act: send, workspaceId: TASK.workspaceId, - onMarkedAll: () => { - rereads += 1; - }, }); drawn = useSettledItems(items); return null; @@ -122,7 +118,6 @@ function marks(first: readonly InboxItem[]) { return { sent, read, - rereads: () => rereads, /** Each item's dot as the hook draws it. */ dots: () => drawn.map((each) => [each.id, isUnread(each)]), mark: (target: InboxItem, unread: boolean) => act(() => held().onMark(target, unread)), @@ -172,12 +167,10 @@ test("marks reach the service one at a time in the order they were made, an item [`unread:${X}`, "all:workspace-acme"], "X's later marks stood for one another, the last already sent, so Mark all as read went next", ); - assert.equal(hook.rereads(), 0); await hook.sent[1]?.answer(); - assert.equal(hook.rereads(), 1, "every item marked read asks the reads again once taken"); }); -test("a mark the service refused is let go, and Mark all as read refused asks nothing again", async () => { +test("a mark the service refused is let go, and Mark all as read refused lets its marks go", async () => { const hook = marks([item(X, false), item(Y, true)]); hook.mark(item(X, false), true); await settle(); @@ -197,7 +190,6 @@ test("a mark the service refused is let go, and Mark all as read refused asks no [X, false], [Y, true], ]); - assert.equal(hook.rereads(), 0); }); test("two opposite marks out at once stand as the latest made, though the held read lands the earlier one's write before the later one goes", async () => { diff --git a/packages/panel/src/tasks/use-inbox-read-marks.ts b/packages/panel/src/tasks/use-inbox-read-marks.ts index b12ee36fbc..01d12f790a 100644 --- a/packages/panel/src/tasks/use-inbox-read-marks.ts +++ b/packages/panel/src/tasks/use-inbox-read-marks.ts @@ -2,7 +2,7 @@ import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; import { INBOX_REPLY, type InboxItem } from "@luke/features/inbox-wire"; import { ACT_KIND } from "@luke/host/acts"; import { APP_COMMAND } from "@luke/settings/shortcuts"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { ActHandle } from "../act.js"; import { useAppCommand } from "../app-commands.js"; import { @@ -61,8 +61,9 @@ interface Wanted { /** * The Inbox's read marks, sent through `act`; every item marked read in - * `workspaceId` where the Inbox lists one, and `onMarkedAll` heard once the - * service took it, so the reads are asked again. + * `workspaceId` where the Inbox lists one. Nothing reads the Inbox again + * once the service took a mark: the followed inboxes are pushed each item's + * read state as it moves. * * A mark stands over every read the window draws (`inbox-settling.ts`) * until a read holds the item changed since, or a held read holds a later @@ -74,20 +75,14 @@ interface Wanted { export function useInboxReadMarks({ act, workspaceId, - onMarkedAll, }: { act: ActHandle["act"]; workspaceId: string | undefined; - onMarkedAll: () => void; }): InboxReadMarks { const settler = useInboxSettler(); // The writes, one after another, and each item's latest mark still to go, which its queued write sends. const queue = useRef>(Promise.resolve()); const wanted = useRef(new Map()); - const heard = useRef(onMarkedAll); - useLayoutEffect(() => { - heard.current = onMarkedAll; - }); const send = useCallback((write: () => Promise) => { queue.current = queue.current.then(write); }, []); @@ -131,7 +126,6 @@ export function useInboxReadMarks({ return; } settler.marked(made); - heard.current(); }), ); }, diff --git a/packages/panel/src/tasks/use-task-inbox.ts b/packages/panel/src/tasks/use-task-inbox.ts index b17b501ce8..4fcf9649ed 100644 --- a/packages/panel/src/tasks/use-task-inbox.ts +++ b/packages/panel/src/tasks/use-task-inbox.ts @@ -3,18 +3,17 @@ import { type CodingAgentCallFailure, type CodingAgentInboxAnsweredView, type CodingAgentInboxAnswerParams, - type CodingAgentInboxAnswerView, type CodingAgentInboxAttachmentParams, type CodingAgentInboxAttachmentView, type CodingAgentInboxMessagedView, type CodingAgentInboxMessageParams, - type CodingAgentInboxParams, + type CodingAgentInboxScope, + INBOX_SCOPE_KIND, } from "@luke/features/coding-agent-view"; import type { CodingAgentAttachment } from "@luke/features/coding-agent-wire"; import { inboxAttachmentIdsIn } from "@luke/features/inbox-attachment-link"; import { INBOX_BLOCK, - INBOX_CURSOR_START, INBOX_STATUS, type InboxItem, type InboxReplyKind, @@ -48,19 +47,19 @@ import { inboxThreadSend, } from "./inbox-model.js"; import { useInboxSettler, useSettledItems } from "./inbox-settling.js"; +import { type InboxFollower, useFollowedInboxes } from "./use-followed-inboxes.js"; /** - * use-task-inbox.ts -- the open task's inbox as one control: its items, read on the service's held long-poll while the window shows the tasks, and the answers on their way back. + * use-task-inbox.ts -- the open task's inbox as one control: its items, followed while the window shows the tasks and pushed as they change, and the answers on their way back. * - * The service holds a read open while nothing changed past the cursor and - * lets it go at its own hold, so the loop is one read after another with no - * clock of its own, as an agent's transcript is read (`use-agent-transcript.ts`); - * each answer is the inbox whole, which replaces what was held. The loop - * runs while the window shows the tasks and a task with an agent, or one - * main says an item waits on, is open, so the tab's - * count stands whichever tab is chosen, and another task starts from - * nothing of this one's. A read that did not answer ends the loop and says - * so, with Try again, rather than asking again at once. + * The inbox is followed (`use-followed-inboxes.ts`) while the window shows + * the tasks and a task with an agent, or one main says an item waits on, + * is open, so the tab's count stands whichever tab is chosen, and another + * task starts from nothing of this one's. Each push is the inbox whole, + * which replaces what was held; nothing here asks again or holds a cursor. + * An item marked read or unread is pushed as any change is, so nothing + * reads it again. An inbox that could not be read is let go and says so, + * with Try again, rather than following it again at once. * * An answer goes under a key minted here and kept with its words, so the * same answer sent again after a lost reply is taken once. It stands over @@ -118,9 +117,6 @@ import { useInboxSettler, useSettledItems } from "./inbox-settling.js"; * an answer taken or a message written lets go of its own. */ -/** How one read is asked: the task and the cursor to read past, answered as the view hears it. */ -export type InboxReader = (params: CodingAgentInboxParams) => Promise; - /** How one answer is sent: the item, its revision, the answer, and the key the send is known by. */ export type InboxAnswerer = ( params: CodingAgentInboxAnswerParams, @@ -309,10 +305,10 @@ function holdsNothing(draft: Draft): boolean { } export function useTaskInbox(input: { - /** The task whose inbox is held, or, with `reader`, the agent with no task whose inbox is. */ + /** The task whose inbox is held, or, with `kind` an agent's, the agent with no task whose inbox is. */ taskId: string | undefined; - /** How the inbox is read where it is one agent's with no task rather than a task's. */ - reader?: ((after: string) => Promise) | undefined; + /** Whose inbox `taskId` names: a task's, unless it is one agent's with no task. */ + kind?: CodingAgentInboxScope["kind"] | undefined; /** Whether the window shows the tasks. */ shown: boolean; /** Whether main's read of what waits across the account says an item waits on the task. */ @@ -320,7 +316,8 @@ export function useTaskInbox(input: { agents: { /** Every agent of the task by id, an archived one among them, whose items still stand. */ readonly heldIds: readonly string[] | undefined; - readonly readInbox: InboxReader; + /** How the inbox is followed and heard pushed. */ + readonly inboxFollower: InboxFollower; readonly answerInbox: InboxAnswerer; readonly messageInbox: InboxMessenger; readonly readInboxImage: InboxImageReader; @@ -329,6 +326,7 @@ export function useTaskInbox(input: { }; }): TaskInboxControl { const { taskId, shown, waiting, agents } = input; + const kind = input.kind ?? INBOX_SCOPE_KIND.PLAN; const [held, setHeld] = useState(() => fresh(taskId)); if (held.taskId !== taskId) setHeld(fresh(taskId)); const [images, setImages] = useState>(new Map()); @@ -343,15 +341,14 @@ export function useTaskInbox(input: { useEffect(() => { setImages((standing) => imagesNamed(standing, named)); }, [named]); - const [attempt, setAttempt] = useState(0); const [sending, setSending] = useState>(new Set()); const [notes, setNotes] = useState>(new Map()); // The cards' drafts, kept apart from the hook so another tab's or page's reading of the inbox // shares them, under the plan, or under none where the inbox is an agent's with no plan. - const ownTask = input.reader === undefined ? taskId : undefined; + const ownTask = kind === INBOX_SCOPE_KIND.PLAN ? taskId : undefined; const draftsOn = ownTask ?? null; // Note that an agent with no plan is the planId its inbox is read by. - const readAgent = input.reader === undefined ? undefined : taskId; + const readAgent = kind === INBOX_SCOPE_KIND.AGENT ? taskId : undefined; const drafts = useInboxDrafts(draftsOn); // The drafts as last drawn, which a refusal landing later reads. const draftsNow = useRef(drafts); @@ -362,42 +359,34 @@ export function useTaskInbox(input: { // The items whose send is out, as `sending` holds them but at once, so a second press before // the render that draws the first sends nothing again. const out = useRef(new Set()); - const latest = useRef({ ...agents, taskId, reader: input.reader }); - latest.current = { ...agents, taskId, reader: input.reader }; + const latest = useRef({ ...agents, taskId }); + latest.current = { ...agents, taskId }; // A task with no agent, standing or archived, has no Inbox tab and nothing - // to read, so it holds no read open, unless an item waits on it, which an - // agent this window has not listed, one started on another Mac, posted. + // to follow, unless an item waits on it, which an agent this window has not + // listed, one started on another Mac, posted. const readable = (agents.heldIds?.length ?? 0) > 0 || waiting; - const active = shown && taskId !== undefined && readable && !held.failed; + const active = shown && taskId !== undefined && readable; + const followed = useFollowedInboxes( + kind, + useMemo(() => (active && taskId !== undefined ? [taskId] : []), [active, taskId]), + agents.inboxFollower, + ); + const pushed = taskId === undefined ? undefined : followed.items.get(taskId); + const notRead = taskId !== undefined && followed.failed.has(taskId); + // Each push is the inbox whole, which replaces what was held, an answer's own patch among it. useEffect(() => { - if (!active || taskId === undefined) return; - let live = true; - const loop = async () => { - let after = INBOX_CURSOR_START; - while (live) { - const { reader, readInbox } = latest.current; - const read = reader === undefined ? readInbox({ taskId: taskId, after }) : reader(after); - const answer = await read.catch((): CodingAgentInboxAnswerView => UNANSWERED); - if (!live) return; - if ("failure" in answer) { - setHeld((was) => ({ ...was, failed: true })); - return; - } - after = answer.cursor; - setHeld((was) => ({ ...was, items: answer.items, read: true })); - } - }; - void loop(); - return () => { - live = false; - }; - }, [active, taskId, attempt]); + if (pushed !== undefined) setHeld((was) => ({ ...was, items: pushed, read: true })); + }, [pushed]); + useEffect(() => { + setHeld((was) => (was.failed === notRead ? was : { ...was, failed: notRead })); + }, [notRead]); + const { onRetry: retryFollow } = followed; const onRetry = useCallback(() => { setHeld((was) => ({ ...was, failed: false })); - setAttempt((count) => count + 1); - }, []); + retryFollow(); + }, [retryFollow]); const note = useCallback((itemId: string, said: InboxNote | undefined) => { setNotes((was) => { @@ -706,7 +695,7 @@ export function useTaskInbox(input: { drafted, onDraft, onAttachments, - reading: active && !held.read, + reading: active && !held.read && !held.failed, read: held.read, failed: held.failed, onRetry, diff --git a/packages/panel/src/tasks/use-taskless-inbox-items.ts b/packages/panel/src/tasks/use-taskless-inbox-items.ts index 777308f5af..ed9878bd23 100644 --- a/packages/panel/src/tasks/use-taskless-inbox-items.ts +++ b/packages/panel/src/tasks/use-taskless-inbox-items.ts @@ -1,152 +1,60 @@ -import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; -import { INBOX_CURSOR_START, type InboxItem } from "@luke/features/inbox-wire"; -import { ACT_KIND } from "@luke/host/acts"; +import { INBOX_SCOPE_KIND } from "@luke/features/coding-agent-view"; +import type { InboxItem } from "@luke/features/inbox-wire"; import type { AppInboxTasklessItem } from "@luke/host/app-state"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useAct } from "../act.js"; +import { useMemo } from "react"; +import { useFollowedInboxes, windowInboxFollower } from "./use-followed-inboxes.js"; /** - * use-taskless-inbox-items.ts -- the items of each agent with no task that something waits on, for the window's Inbox, each agent read whole once and again only when main says its items moved. + * use-taskless-inbox-items.ts -- the items of each agent with no task that something waits on, for the window's Inbox, each agent followed while something waits on it and pushed as its items change. * * Main's read of the account carries the items of agents with no task that * wait on the developer, in brief, as it carries a task's. So each such - * agent is read whole here, its settled items as well, from the start, - * which the service answers at once, and read again only when the items - * main lists as waiting on it move, one answered or one opened, rather - * than holding a read open on every agent; the agent whose item is chosen - * holds its own read open besides, as a task's does (`use-task-inbox.ts`). - * An answer a newer ask overtook is let go. A read that did not answer - * leaves the agent as it was last read, or unread, and says so, with Try - * again, which asks again of it whether or not it was read before. + * agent is followed whole here (`use-followed-inboxes.ts`), its settled + * items as well, and pushed to the window as they change rather than read + * again. An agent that could not be read leaves its items as they were last + * pushed, or none, and says so, with Try again; one nothing waits on any + * more keeps its items as last pushed. */ -const UNANSWERED = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; - /** What the window's Inbox lists of the agents with no task. */ export interface TasklessInboxItems { - /** Each agent's items as last read, by agent; an agent not yet read is absent. */ + /** Each agent's items as last pushed, by agent; an agent not yet pushed is absent. */ readonly items: ReadonlyMap; - /** Whether an agent's last read did not answer, so the list offers to try again. */ + /** Whether an agent something waits on could not be read, so the list offers to try again. */ readonly failed: boolean; - /** The agents whose items are read as of main's latest word of what waits on them, or read and waited on no more, so an item they do not hold is not theirs. */ + /** The agents pushed holding every item main says waits on them, or pushed and waited on no more, which stand for them whole, so an item they do not hold is not theirs. */ readonly current: ReadonlySet; readonly onRetry: () => void; - /** Reads every agent's items again, as of now, though nothing main hears of moved: what every item marked read needs. */ - readonly onReread: () => void; -} - -/** One agent's items as last read, and the waiting items that read answered for. */ -interface Read { - readonly items: readonly InboxItem[]; - readonly waitingIds: readonly string[]; -} - -/** The items main lists as waiting on each agent, by id in id order: what an agent is read again on, an item answered or opened since moving it. */ -function waitingPerAgent( - waiting: readonly AppInboxTasklessItem[], -): ReadonlyMap { - const ids = new Map(); - for (const item of waiting) { - const held = ids.get(item.agentId) ?? []; - held.push(item.id); - ids.set(item.agentId, held); - } - return new Map([...ids].map(([agentId, held]) => [agentId, held.toSorted()])); -} - -/** Whether two agents' waiting items are the same items. */ -function sameItems(one: readonly string[] | undefined, other: readonly string[]): boolean { - return ( - one !== undefined && one.length === other.length && one.every((id, at) => id === other[at]) - ); } export function useTasklessInboxItems( waiting: readonly AppInboxTasklessItem[], ): TasklessInboxItems { - const { act } = useAct(); - const [reads, setReads] = useState>(new Map()); - const [failed, setFailed] = useState>(new Set()); - const [attempt, setAttempt] = useState(0); - const standing = useMemo(() => waitingPerAgent(waiting), [waiting]); - // The waiting items each agent's held read answered for, so an agent is read again only when they move. - const readFor = useRef(new Map()); - // Each agent's ask out now, by its number and the waiting items it asks for: an answer is taken - // only for an agent's latest ask, so one overtaken, even for items it waited on before, is let go. - const asking = useRef(new Map()); - const asks = useRef(0); - const live = useRef(true); - useEffect(() => { - live.current = true; - return () => { - live.current = false; - }; - }, []); - - useEffect(() => { - void attempt; - for (const [agentId, items] of standing) { - // Note that an agent whose items came back to what its read answered for lets go of an ask - // still out for the items between, so that ask's late answer draws nothing over it. - if (sameItems(readFor.current.get(agentId), items)) { - asking.current.delete(agentId); - continue; - } - if (sameItems(asking.current.get(agentId)?.items, items)) continue; - asks.current += 1; - const seq = asks.current; - asking.current.set(agentId, { seq, items }); - act(ACT_KIND.CODING_AGENTS_AGENT_INBOX, { agentId, after: INBOX_CURSOR_START }) - .catch(() => UNANSWERED) - .then((answer) => { - if (!live.current || asking.current.get(agentId)?.seq !== seq) return; - asking.current.delete(agentId); - const failure = "failure" in answer; - setFailed((was) => { - if (was.has(agentId) === failure) return was; - const next = new Set(was); - if (failure) next.add(agentId); - else next.delete(agentId); - return next; - }); - if ("failure" in answer) return; - readFor.current.set(agentId, items); - setReads((was) => new Map(was).set(agentId, { items: answer.items, waitingIds: items })); - }); - } - }, [act, standing, attempt]); - - // A retry asks again of every agent whose read failed, one read before among them, its last answer left drawn until the new one lands. - const failedNow = useRef(failed); - failedNow.current = failed; - const onRetry = useCallback(() => { - for (const agentId of failedNow.current) readFor.current.delete(agentId); - asking.current.clear(); - setFailed(new Set()); - setAttempt((count) => count + 1); - }, []); - // Note that each agent's last read stays drawn until the new one lands. - const onReread = useCallback(() => { - readFor.current.clear(); - asking.current.clear(); - setAttempt((count) => count + 1); - }, []); - const items = useMemo( - () => new Map([...reads].map(([agentId, read]) => [agentId, read.items])), - [reads], + const ids = useMemo(() => waiting.map((item) => item.agentId), [waiting]); + const { items, failed, onRetry } = useFollowedInboxes( + INBOX_SCOPE_KIND.AGENT, + ids, + windowInboxFollower, ); - // Note that an agent nothing waits on any more has no newer read on its way, so its last read stands. - const current = new Set( - [...reads.keys()].filter((agentId) => { - const waitingIds = standing.get(agentId); - return waitingIds === undefined || sameItems(reads.get(agentId)?.waitingIds, waitingIds); - }), + // Note that an agent main lists an item waiting on that its push does not hold yet is not current, + // because its push may land a moment after main's own word of the item. + const current = useMemo( + () => + new Set( + [...items].flatMap(([agentId, held]) => + waiting.every( + (item) => item.agentId !== agentId || held.some((each) => each.id === item.id), + ) + ? [agentId] + : [], + ), + ), + [items, waiting], ); return { items, - failed: [...standing.keys()].some((agentId) => failed.has(agentId)), + failed: ids.some((agentId) => failed.has(agentId)), current, onRetry, - onReread, }; } diff --git a/packages/panel/src/tasks/use-waiting-items.ts b/packages/panel/src/tasks/use-waiting-items.ts index c48b6f4f82..209b488ac9 100644 --- a/packages/panel/src/tasks/use-waiting-items.ts +++ b/packages/panel/src/tasks/use-waiting-items.ts @@ -1,7 +1,4 @@ -import type { CodingAgentInboxParams } from "@luke/features/coding-agent-view"; -import { ACT_KIND } from "@luke/host/acts"; -import { useCallback, useMemo } from "react"; -import { useAct } from "../act.js"; +import { useMemo } from "react"; import { type InboxListed, openItems, taskHolder, tasklessGroups } from "./inbox-model.js"; import { useSettledReads } from "./inbox-settling.js"; import { useInboxItems } from "./use-inbox-items.js"; @@ -12,9 +9,9 @@ import type { TasksControl } from "./use-tasks-tab.js"; * use-waiting-items.ts -- every open Inbox item of the listed workspace, a teammate's agent's among them, as Home and Factory list what waits on the developer. * * Main's read of the account counts only what waits on its own agents, so - * a place that lists what waits as the window's Inbox does reads each plan + * a place that lists what waits as the window's Inbox does follows each plan * holding an item whole, and each agent with no plan main says something - * waits on, through the Inbox's own reads. Every open item is listed, whoever + * waits on, as the Inbox follows them. Every open item is listed, whoever * it waits on, since Factory counts what holds a teammate's task up and a * person's page what waits on them; Home keeps only the reader's own * (`needsReader`). @@ -41,11 +38,6 @@ export interface WaitingItems { * something waits on. */ export function useWaitingItems(tasks: TasksControl): WaitingItems { - const { act } = useAct(); - const readInbox = useCallback( - (params: CodingAgentInboxParams) => act(ACT_KIND.CODING_AGENTS_INBOX, params), - [act], - ); const { inboxTasks, tasks: listed } = tasks.listed; // Note that every plan holding an item is read, not only those main counts as waiting on the // account, because those are its own agents' alone, and the Inbox lists every member's. @@ -53,9 +45,9 @@ export function useWaitingItems(tasks: TasksControl): WaitingItems { () => inboxTasks.filter((holding) => listed.some((task) => task.id === holding.taskId)), [inboxTasks, listed], ); - const read = useInboxItems({ holdings, readInbox }); + const read = useInboxItems({ holdings }); const tasklessReads = useTasklessInboxItems(tasks.inboxTaskless); - // Note that the window's own answers and messages stand over the agents' reads until they hold them, so a stale read never brings an answered item back. + // Note that the window's own answers and messages stand over the agents' pushes until they hold them, so a stale push never brings an answered item back. const tasklessItems = useSettledReads(tasklessReads.items); const taskless = { ...tasklessReads, items: tasklessItems }; const entries = [ @@ -69,7 +61,7 @@ export function useWaitingItems(tasks: TasksControl): WaitingItems { ]; // Note that every open item is kept as one that waits, whoever started its agent. const items = openItems(entries); - // Note that a plan read before it last changed is still being read, because a retry's newer read may yet answer what the older one did not hold. + // Note that a listed plan not pushed yet is still being read. const reading = read.pending || tasks.inboxTaskless.some((each) => !taskless.items.has(each.agentId)); const onRetry = () => { diff --git a/packages/panel/src/testing/followed-inboxes.ts b/packages/panel/src/testing/followed-inboxes.ts new file mode 100644 index 0000000000..a55c22d5a6 --- /dev/null +++ b/packages/panel/src/testing/followed-inboxes.ts @@ -0,0 +1,81 @@ +/** + * followed-inboxes.ts -- what a test of a surface that draws an inbox stands on: the host's side of the window's inbox streams, pushing each followed plan's or agent's inbox as the test holds it, and each change a test pushes. + */ + +import type { + CodingAgentInboxChanged, + CodingAgentInboxScope, +} from "@luke/features/coding-agent-view"; +import { AGENT_INBOX_ACT_KIND } from "@luke/host/contract"; +import { AsyncIteratorClass } from "@orpc/client"; +import type { InboxFollower } from "../tasks/use-followed-inboxes.js"; +import type { FakeActPort } from "./app-bridge.js"; + +type Inbox = CodingAgentInboxChanged["inbox"]; + +/** One stream's listener, by the scope it follows. */ +interface Listening { + readonly scope: CodingAgentInboxScope; + readonly hear: (inbox: Inbox) => void; +} + +/** + * The host's inbox streams for one window: `inboxOf` says what a followed + * inbox holds, pushed a moment after it is followed as the host pushes it, + * or nothing yet where it says nothing; `push` pushes a change of one the + * window follows. Its procedure is spread into a test's `fakeActPort`, and + * its follower handed where a plan's agents hand theirs. + */ +export function followedInboxes(inboxOf: (scope: CodingAgentInboxScope) => Inbox | undefined) { + const listening = new Set(); + /** Every follow the window asked for, in order, as `kind:id`. */ + const asked: string[] = []; + const isFollowed = (scope: CodingAgentInboxScope) => + [...listening].some((each) => each.scope.kind === scope.kind && each.scope.id === scope.id); + const push = (scope: CodingAgentInboxScope, inbox: Inbox) => { + for (const each of [...listening]) { + if (each.scope.kind === scope.kind && each.scope.id === scope.id) each.hear(inbox); + } + }; + const follower: InboxFollower = { + follow: (scope, hear) => { + asked.push(`${scope.kind}:${scope.id}`); + const entry: Listening = { scope, hear }; + listening.add(entry); + void Promise.resolve().then(() => { + const inbox = inboxOf(scope); + if (inbox !== undefined && listening.has(entry)) hear(inbox); + }); + return () => void listening.delete(entry); + }, + }; + /** The follow procedure as the act port streams it: each change heard, until the window closes the stream. */ + const procedures: Pick = { + [AGENT_INBOX_ACT_KIND.CODING_AGENTS_INBOX_FOLLOW]: (scope) => { + const queued: Inbox[] = []; + let waiting: PromiseWithResolvers | undefined; + let closed = false; + const stop = follower.follow(scope, (inbox) => { + queued.push(inbox); + waiting?.resolve(); + }); + const ended = (): IteratorReturnResult => ({ done: true, value: undefined }); + const next = async (): Promise> => { + while (queued.length === 0 && !closed) { + waiting = Promise.withResolvers(); + await waiting.promise; + } + const inbox = queued.shift(); + return inbox === undefined || closed ? ended() : { done: false, value: inbox }; + }; + return Promise.resolve( + new AsyncIteratorClass(next, async () => { + closed = true; + stop(); + waiting?.resolve(); + }), + ); + }, + }; + return { asked, push, isFollowed, follower, procedures }; +} diff --git a/packages/panel/src/testing/tasks-control.ts b/packages/panel/src/testing/tasks-control.ts index b81f7a2399..285afcc14c 100644 --- a/packages/panel/src/testing/tasks-control.ts +++ b/packages/panel/src/testing/tasks-control.ts @@ -169,7 +169,7 @@ export function codingAgentsControl( Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), readPullRequestViewed: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), changesPeople: new Map(), - readInbox: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), + inboxFollower: { follow: () => () => undefined }, answerInbox: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), messageInbox: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), readInboxImage: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }),