diff --git a/apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx b/apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx index 0a5960b6d..1cddb181e 100644 --- a/apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx +++ b/apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { presentSessionAgentLiveActivity, + presentSessionAgentLiveActivityAgentSummary, sessionAgentLiveActivityTextRows, sessionAgentLiveActivityUnavailableLabel, } from "@t3tools/client-runtime/state/session-agent-live-activity"; @@ -10,6 +11,7 @@ import { type ProviderSessionAgentActivitySnapshot, type ThreadId, } from "@t3tools/contracts"; +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Cause from "effect/Cause"; import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native"; @@ -20,11 +22,13 @@ export function SessionAgentLiveActivityModal({ environmentId, threadId, agentId, + agent, onClose, }: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly agentId: string; + readonly agent: Pick; readonly onClose: () => void; }) { const result = useAtomValue( @@ -71,7 +75,7 @@ export function SessionAgentLiveActivityModal({ Loading live activity… ) : ( - + )} @@ -81,29 +85,52 @@ export function SessionAgentLiveActivityModal({ export function SessionAgentLiveActivitySnapshot({ snapshot, + agent, }: { readonly snapshot: ProviderSessionAgentActivitySnapshot; + readonly agent: Pick; }) { const presentation = presentSessionAgentLiveActivity(snapshot); - if (presentation.entries.length === 0) { - return ( - - No assistant activity yet. - - ); - } + const summary = presentSessionAgentLiveActivityAgentSummary(agent); return ( - - - {sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => ( - - {entry.text} + + + {summary.statusLabel} + {summary.activityLabel === null ? null : ( + {summary.activityLabel} + )} + {summary.usageLabel === null ? null : ( + + {summary.usageLabel} - ))} + )} - - Latest bounded snapshot · Live only - - + {presentation.entries.length === 0 ? ( + + + No assistant text yet. + + + Tool arguments, results, and reasoning are not shown. + + + ) : ( + + + {sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => ( + + {entry.text} + + ))} + + + Latest bounded snapshot · Live only + + + )} + ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9ed5e193d..acb6a90aa 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -2021,6 +2021,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentId={props.environmentId} threadId={props.selectedThread.id} agentId={selectedLiveActivityAgent.id} + agent={selectedLiveActivityAgent} onClose={() => setLiveActivitySelection(null)} /> ) : null} diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts index f01abd2b9..eedefd647 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts @@ -2276,6 +2276,25 @@ describe("Prime Agent live activity privacy boundary", () => { expect(JSON.stringify(entries)).not.toContain("native-tool"); }); + it("returns an empty snapshot for realistic thinking and tool-only activity", () => { + expect( + sanitizePrimeAgentLiveActivityMessages([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "toolCall", id: "native-tool", name: "ipython", arguments: { path: "/tmp" } }, + ], + }, + { + role: "toolResult", + toolName: "ipython", + content: [{ type: "text", text: "private result" }], + }, + ]), + ).toEqual([]); + }); + it.effect("coalesces watcher events and closes the second connection when the stream ends", () => Effect.gen(function* () { let messages: ReadonlyArray = [ @@ -2412,6 +2431,72 @@ describe("Prime Agent live activity privacy boundary", () => { ), ); + it.effect("does not count invisible initialization events against the bounded buffer", () => + Effect.scoped( + Effect.gen(function* () { + let markReadStarted!: () => void; + let resolveInitialRead!: (messages: ReadonlyArray) => void; + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const initialRead = new Promise>((resolve) => { + resolveInitialRead = resolve; + }); + const { emitWatch, make } = fixture({ + getWatchMessages: () => { + markReadStarted(); + return initialRead; + }, + }); + const runtime = yield* make(); + const fiber = yield* runtime + .watchAgentActivity("native-child-active") + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.promise(() => readStarted); + for (let index = 0; index < 128; index += 1) { + yield* Effect.promise(() => + emitWatch({ + type: "session_event", + event: { + type: "message_update", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: `private-${index}` }, + { + type: "toolCall", + id: `native-${index}`, + name: "ipython", + arguments: { path: "/private/path" }, + }, + ], + }, + }, + }), + ); + } + yield* Effect.promise(() => + emitWatch({ + type: "session_event", + event: { + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "visible answer" }], + }, + }, + }), + ); + resolveInitialRead([]); + + expect(Array.from(yield* Fiber.join(fiber))).toEqual([ + [], + [{ speaker: "assistant", text: "visible answer" }], + ]); + }), + ), + ); + it.effect("fails bounded initialization buffering instead of retaining unlimited events", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts index a7390b390..afc0e1355 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts @@ -2121,13 +2121,15 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo return undefined; } const visible = sanitizePrimeAgentLiveActivityMessages([nativeEvent.message]); + const message = visible[0]; + // Tool/reasoning-only events cannot affect the public snapshot, + // so they must not consume the bounded initialization budget. + if (message === undefined) return undefined; return { type: "session_event", event: { type: nativeEvent.type, - ...(visible[0] === undefined - ? {} - : { message: safeAssistantMessage(visible[0].text) }), + message: safeAssistantMessage(message.text), }, }; }; diff --git a/apps/web/src/components/AgentLiveActivity.tsx b/apps/web/src/components/AgentLiveActivity.tsx index dd72b7de8..2185c8008 100644 --- a/apps/web/src/components/AgentLiveActivity.tsx +++ b/apps/web/src/components/AgentLiveActivity.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { presentSessionAgentLiveActivity, + presentSessionAgentLiveActivityAgentSummary, sessionAgentLiveActivityTextRows, sessionAgentLiveActivityUnavailableLabel, } from "@t3tools/client-runtime/state/session-agent-live-activity"; @@ -10,6 +11,7 @@ import { type ProviderSessionAgentActivitySnapshot, type ThreadId, } from "@t3tools/contracts"; +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Cause from "effect/Cause"; import { orchestrationEnvironment } from "~/state/orchestration"; @@ -18,10 +20,12 @@ export function AgentLiveActivity({ environmentId, threadId, agentId, + agent, }: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly agentId: string; + readonly agent: Pick; }) { const result = useAtomValue( orchestrationEnvironment.sessionAgentLiveActivity({ @@ -45,40 +49,60 @@ export function AgentLiveActivity({ ); } - return ; + return ; } export function AgentLiveActivitySnapshot({ snapshot, + agent, }: { readonly snapshot: ProviderSessionAgentActivitySnapshot; + readonly agent: Pick; }) { const presentation = presentSessionAgentLiveActivity(snapshot); - if (presentation.entries.length === 0) { - return ( -

- No assistant activity yet. -

- ); - } + const summary = presentSessionAgentLiveActivityAgentSummary(agent); return ( -
-
- {sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => ( -

- {entry.text} -

- ))} +
+
+

{summary.statusLabel}

+ {summary.activityLabel === null ? null : ( +

{summary.activityLabel}

+ )} + {summary.usageLabel === null ? null : ( +

{summary.usageLabel}

+ )}
-

- Latest bounded snapshot · Live only -

+ {presentation.entries.length === 0 ? ( +
+

No assistant text yet.

+

Tool arguments, results, and reasoning are not shown.

+
+ ) : ( +
+
+ {sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => ( +

+ {entry.text} +

+ ))} +
+

+ Latest bounded snapshot · Live only +

+
+ )}
); } diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx index dc332035b..4fd406996 100644 --- a/apps/web/src/components/AgentsPanel.test.tsx +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -137,13 +137,26 @@ describe("AgentsPanel agent cancellation", () => { expect(gated).not.toContain("Live activity unavailable"); }); - it("renders empty and bounded assistant-only replacement snapshots", () => { + it("renders safe aggregate status with empty and bounded assistant-only snapshots", () => { + const liveAgent = { + ...active, + lastToolName: "ipython", + usage: { totalTokens: 65_800, toolUses: 14 }, + progress: "private progress", + }; const empty = renderToStaticMarkup( , ); - expect(empty).toContain("No assistant activity yet."); + expect(empty).toContain("Working"); + expect(empty).toContain('aria-live="polite"'); + expect(empty).toContain("Last tool: ipython"); + expect(empty).toContain("65.8k tokens · 14 tools"); + expect(empty).toContain("No assistant text yet."); + expect(empty).toContain("Tool arguments, results, and reasoning are not shown."); + expect(empty).not.toContain("private progress"); const snapshot = { agentId: "canonical", @@ -156,7 +169,9 @@ describe("AgentsPanel agent cancellation", () => { usage: "private usage", metadata: "private metadata", } as unknown as ProviderSessionAgentActivitySnapshot; - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup( + , + ); expect(markup).toContain("Safe assistant update"); expect(markup).toContain("Latest bounded snapshot · Live only"); expect(markup).not.toContain("private-native-id"); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 8cd6f64f8..0b72d0ac3 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -936,6 +936,7 @@ export function AgentsPanel({ environmentId={environmentId} threadId={threadId} agentId={selectedLiveActivityAgent.id} + agent={selectedLiveActivityAgent} /> ) : null} diff --git a/docs/internals/prime-agent-daemon-parity.md b/docs/internals/prime-agent-daemon-parity.md index 758b8e341..bef141838 100644 --- a/docs/internals/prime-agent-daemon-parity.md +++ b/docs/internals/prime-agent-daemon-parity.md @@ -33,7 +33,7 @@ Prime event queues are bounded to 256 entries and preserve FIFO delivery with ba | `getResourceSnapshot` | Partially integrated by safe outcome | Commands and safe skill/prompt metadata are decoded internally. Native paths, diagnostics, extensions, themes, packages, and MCP configuration are not sent to clients. | | `respondToExtensionUiRequest` | Partially integrated by safe outcome | Select, confirm, and input dialogs plus bounded notifications, status, and widgets are correlated without exposing native request envelopes. Submitted free-form input uses a transient provider RPC and is redacted from durable activities. Editor replacement is cancelled because its prefill may contain sensitive model or tool material that cannot safely enter Pylon's synchronized event stream. | | `getRlmMaxDepthStatus`, `setRlmMaxDepth`, `cancelRlmChild`, `sendAgentMessage` | Integrated | Canonical Pylon task IDs resolve through the private live roster. Messaging is ephemeral. | -| `watchSession`; watcher `subscribe`, `getMessages`, `close` | Integrated for bounded child live activity | A short-lived watcher attaches only to an explicitly selected active descendant. Its `getMessages` supplies one bounded, sanitized assistant-only initialization snapshot, then watcher events maintain active-only live activity until the watcher closes. This is distinct from, and does not enable, root transcript reads. | +| `watchSession`; watcher `subscribe`, `getMessages`, `close` | Integrated for bounded child live activity | A short-lived watcher attaches only to an explicitly selected active descendant. Its `getMessages` supplies one bounded, sanitized assistant-only committed-message snapshot, then watcher events maintain active-only live activity until the watcher closes. The public watcher does not atomically expose an attach-time `streamingMessage`, so Pylon does not attempt a private-API workaround for a partial already in flight. This is distinct from, and does not enable, root transcript reads. | | `getAgentMessageStatus`, `pauseAgentMessages`, `resumeAgentMessages`, `clearAgentMessages` | Intentionally excluded | These controls are daemon-global and can change or clear traffic belonging to unrelated sessions. | | `startSideQuestion`, `abortSideQuestion` | Integrated as constrained transient quick questions | Supervised, fresh sessions may run one bounded tool-free question through a requester-owned unary RPC. Pylon uses separate public/native IDs, returns only one temporary answer, requests one bounded abort on cancellation, timeout, or disconnect, and never retries or persists the prompt, answer, native errors, or lifecycle. Full-access extension hooks, restored sessions, ACP, follow-up transcripts, and reconnect recovery fail closed. | | `getHeartbeat`, `setHeartbeat`, `updateHeartbeat`, `listHeartbeats`, `listCronJobs`, `addCronJob`, `cancelCronJob`, `manageHeartbeat` | Deferred on lifecycle ownership | Scheduling promotes work to resident ownership, but public APIs do not provide Pylon an authoritative autonomous-turn/checkpoint identity, demotion, reattachment, or fail-safe delete flow. Shipping now could leave invisible mutations or orphaned work. | diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 0b58ab90a..961fe1f00 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -76,7 +76,7 @@ blocking interactions, approvals, and resource reloads so remote clients do not would only fail as busy. The setter never passes the global persistence option, and supervised sessions remain policy-fixed at zero. Agent cancellation uses a provider-neutral, operate-scoped RPC keyed by the Pylon thread and the already-projected opaque task ID. The Prime adapter validates that ID against the thread's known active descendant roster before calling the public `cancelRlmChild` API; it never accepts a native active-session selector. Duplicate cancellation requests coalesce while the native terminal update is pending. Native cancellation has a fixed deadline; `false`, a racing completion, a failed call, or a timed-out response triggers one reconciliation against the latest decoded roster rather than a mutation retry. If that roster cannot restore authority, the session is closed. Prime's public `getInitialSnapshot()` does not refetch live children, so the runtime seeds this private roster from attach/resync snapshots and updates it synchronously from bounded child events before exposing those events. Initial active-child and reconnect snapshots reconcile task rows, and a previously active child missing from an authoritative live-descendant snapshot is settled so clients cannot retain an uncontrollable working row. The first native terminal child update remains authoritative; later terminal repeats are ignored so cancellation races cannot append duplicate terminal activities. Tool results containing native child handles or session paths are replaced at the Prime decoding boundary before they can enter runtime events. Native agent messaging is a separate operate-scoped provider RPC keyed by the same canonical task ID. The adapter consults its current private event-driven roster, resolves that ID to a bounded native active-session endpoint, and invokes public `sendAgentMessage` exactly once under the thread mutation lock. Only `delivered` or `queued` acceptance crosses back to the initiating client; native receipt IDs, sender/target identities, timestamps, echoed text, and delivery errors are discarded. Pylon persists no sent-message content or receipt activity, while Prime necessarily retains the message in the child session's private transcript. Post-invocation failure is reported as delivery uncertainty and is never retried automatically. A provider-neutral `messageable` boolean tells clients which live rows currently have an endpoint without exposing it. Prime's daemon-global agent-message pause/resume/clear controls remain unavailable because their cross-session effects are unsafe for Pylon's multi-client provider model. Web, desktop, and mobile gate message and stop affordances on the active session's advertised agent operations rather than the provider name. -Live child activity is a separate read-scoped, non-orchestration stream. The client supplies a canonical task ID already present in the thread's active-agent projection; the Prime adapter resolves it only through its private authoritative descendant roster before calling public `watchSession`. Concurrent subscribers for the same child and native endpoint share one reference-counted read-only native attachment, while revisions and lifetime quotas remain subscriber-local. The server sanitizes the initial committed messages and Prime's public replacement, resync, and message-stream events into bounded replacement snapshots, retaining only non-empty assistant text parts; child prompts, system/developer messages, tool calls and results, thinking, attachments, errors, usage, native identities, timestamps, and envelopes terminate at the Prime boundary. Snapshot size, entry count, update count, lifetime characters, initialization events, and concurrent watchers are hard-capped. Duplicate snapshots are suppressed and native event bursts are debounced. No runtime event or orchestration activity is created, and durable child lifecycle projections discard native answer previews, recaps, and errors, so neither SQLite nor clients without an open view receive the assistant text. Stream finalizers close the shared watcher after its last panel owner leaves, or immediately on WebSocket cancellation, roster settlement/removal, endpoint replacement, session stop, provider replacement, or scope shutdown. Prime Agent 0.7.2 can attach only to a currently live child and exposes neither an atomic history cursor nor reopen for exited children; capability and UI wording therefore promise only **Live activity**, never a durable or lossless transcript. +Live child activity is a separate read-scoped, non-orchestration stream. The client supplies a canonical task ID already present in the thread's active-agent projection; the Prime adapter resolves it only through its private authoritative descendant roster before calling public `watchSession`. Concurrent subscribers for the same child and native endpoint share one reference-counted read-only native attachment, while revisions and lifetime quotas remain subscriber-local. The server sanitizes the initial committed messages and Prime's public replacement, resync, and message-stream events into bounded replacement snapshots, retaining only non-empty assistant text parts; child prompts, system/developer messages, tool calls and results, thinking, attachments, errors, usage, native identities, timestamps, and envelopes terminate at the Prime boundary. Snapshot size, entry count, update count, lifetime characters, initialization events, and concurrent watchers are hard-capped. Watcher events that cannot affect the sanitized public snapshot are discarded before initialization admission. Duplicate snapshots are suppressed and native event bursts are debounced. No runtime event or orchestration activity is created, and durable child lifecycle projections discard native answer previews, recaps, and errors, so neither SQLite nor clients without an open view receive the assistant text. Stream finalizers close the shared watcher after its last panel owner leaves, or immediately on WebSocket cancellation, roster settlement/removal, endpoint replacement, session stop, provider replacement, or scope shutdown. Prime Agent 0.7.2 can attach only to a currently live child and exposes neither an atomic history cursor nor reopen for exited children; capability and UI wording therefore promise only **Live activity**, never a durable or lossless transcript. Quick questions use a separate operate-scoped unary RPC and never enter provider runtime ingestion or orchestration. The per-WebSocket handler owns a Pylon request ID, while the Prime adapter maps it to an unguessable native ID and accepts only exact correlated terminal events. It returns one answer after completion; prompt text, cumulative native updates, errors, IDs, and lifecycle never cross into durable state or other Pylon clients. Questions and answers have UTF-8 and character bounds, cumulative native traffic and updates are capped, and at most one question per thread plus a provider-wide concurrency limit can run for two minutes. Cancellation, timeout, request interruption, WebSocket disconnect, session replacement, and scope shutdown run one best-effort native abort without retry. Because Prime 0.7.2 side agents inherit provider extension hooks even with `tools: []`, this operation is admitted only in fresh supervised sessions where discovery is disabled and Pylon's verified permission extension cannot act on a tool-free response. Full-access, restored, and ACP sessions fail closed. diff --git a/docs/user/providers-prime-agent.md b/docs/user/providers-prime-agent.md index 3f67760be..e2bd6b685 100644 --- a/docs/user/providers-prime-agent.md +++ b/docs/user/providers-prime-agent.md @@ -79,7 +79,7 @@ native session rather than risking a partially reloaded runtime; it never retrie send resource paths, diagnostics, or extension source details to clients. Supervised sessions keep discovered commands disabled. Observed Prime subagents appear in Pylon's Agents hierarchy. In Full access, an active agent can be stopped from its Agents row on web or desktop, or from the **Agents** control on mobile. Pylon waits for Prime's native cancelled status instead of marking the agent stopped optimistically; completed output and activity remain in the thread. A cancellation racing natural completion is treated as already settled, and Pylon never retries an uncertain cancellation automatically. Supervised sessions do not offer this control because child-agent spawning is disabled. In Full access, a live agent with a native message endpoint can also receive a direct message from its Agents row. Pylon reports only whether Prime delivered the message immediately or queued it behind current work; that receipt does not mean the agent read, answered, or completed it. Pylon does not copy the message or Prime's receipt identifiers into its event store, activity history, diagnostics, or other clients. Prime necessarily adds the text to the selected child agent's private native transcript and context so the agent can act on it. Sending is never retried automatically; if delivery becomes uncertain, sending again may duplicate the message. Supervised and ACP sessions do not offer native agent messaging. -For an active agent, **Live activity** opens an on-demand, assistant-only view on web, desktop, or mobile. It is a bounded replacement snapshot from Prime's public live-session watcher, not a durable transcript: Pylon does not persist it in the thread, share it with clients that did not open the view, or keep it after the panel closes. Child prompts, tool calls and results, thinking, attachments, errors, usage, native identifiers, and session metadata are excluded. The subscription closes when the view closes, the agent exits, the thread or provider changes, or the client disconnects. Prime Agent 0.7.2 cannot reopen an exited child or provide lossless historical child activity, so Pylon labels the view **Live only** rather than implying history. +For an active agent, **Live activity** opens an on-demand, assistant-only view on web, desktop, or mobile. It is a bounded replacement snapshot from Prime's public live-session watcher, not a durable transcript: Pylon does not persist it in the thread, share it with clients that did not open the view, or keep it after the panel closes. The panel repeats the safe aggregate status already shown in the agent roster, such as the latest tool name and token or tool counts. Child prompts, tool arguments and results, thinking, attachments, errors, usage details, native identifiers, and session metadata are excluded. **No assistant text yet** means the agent may still be thinking or using tools; it does not mean the agent is inactive. The subscription closes when the view closes, the agent exits, the thread or provider changes, or the client disconnects. Prime Agent 0.7.2 cannot reopen an exited child, provide lossless historical child activity, or atomically expose assistant text that was already streaming when the view opened, so Pylon labels the view **Live only** rather than implying history. Supervised daemon sessions also expose **Quick question** in the composer. It asks the selected session model one tool-free question against a snapshot of the current conversation, then returns one temporary answer. The question and answer are sent only to the requesting client: Pylon does not add them to the thread, checkpoint them, synchronize them to other clients, or retry them after a disconnect. Closing or cancelling the request makes one best-effort native abort, and a timeout or uncertain outcome stays explicit. Quick questions can still consume model tokens and incur provider charges. diff --git a/packages/client-runtime/src/state/sessionAgentLiveActivity.test.ts b/packages/client-runtime/src/state/sessionAgentLiveActivity.test.ts index 8c8f84582..6799bbaf4 100644 --- a/packages/client-runtime/src/state/sessionAgentLiveActivity.test.ts +++ b/packages/client-runtime/src/state/sessionAgentLiveActivity.test.ts @@ -4,6 +4,7 @@ import { canWatchSessionAgentLiveActivity, SESSION_AGENT_LIVE_ACTIVITY_IDLE_TTL_MS, presentSessionAgentLiveActivity, + presentSessionAgentLiveActivityAgentSummary, replaceSessionAgentLiveActivity, sessionAgentLiveActivitySelectionIsOpen, sessionAgentLiveActivityTextRows, @@ -49,6 +50,22 @@ describe("session agent live activity", () => { expect(supportsSessionAgentLiveActivity(null)).toBe(false); }); + it("presents only the safe aggregate activity already visible in the roster", () => { + expect( + presentSessionAgentLiveActivityAgentSummary({ + lastToolName: "ipython", + usage: { totalTokens: 65_800, toolUses: 14 }, + }), + ).toEqual({ + statusLabel: "Working", + activityLabel: "Last tool: ipython", + usageLabel: "65.8k tokens · 14 tools", + }); + expect( + presentSessionAgentLiveActivityAgentSummary({ lastToolName: null, usage: null }), + ).toEqual({ statusLabel: "Working", activityLabel: null, usageLabel: null }); + }); + it("closes stale selections on provider/runtime switches and agent settlement", () => { const advertised = provider("read-write", ["live-activity"]); expect( diff --git a/packages/client-runtime/src/state/sessionAgentLiveActivity.ts b/packages/client-runtime/src/state/sessionAgentLiveActivity.ts index 0da638523..c3ad98dcb 100644 --- a/packages/client-runtime/src/state/sessionAgentLiveActivity.ts +++ b/packages/client-runtime/src/state/sessionAgentLiveActivity.ts @@ -1,5 +1,7 @@ import type { ProviderSessionAgentActivitySnapshot, ServerProvider } from "@t3tools/contracts"; +import { formatSubagentTokenCount, type RuntimeSubagent } from "./subagentRuntime.ts"; + /** Zero retention ensures closing the detail immediately releases its RPC owner. */ export const SESSION_AGENT_LIVE_ACTIVITY_IDLE_TTL_MS = 0; @@ -54,6 +56,37 @@ export interface SessionAgentLiveActivityPresentation { readonly entries: ReadonlyArray; } +export interface SessionAgentLiveActivityAgentSummary { + readonly statusLabel: "Working"; + readonly activityLabel: string | null; + readonly usageLabel: string | null; +} + +/** + * Reuses only the safe aggregate fields already visible in the agent roster. + * Prompts, tool arguments and results, reasoning, and native identifiers never + * enter this presentation. + */ +export function presentSessionAgentLiveActivityAgentSummary( + agent: Pick, +): SessionAgentLiveActivityAgentSummary { + const usage = agent.usage; + const usageLabel = + usage === null + ? null + : [ + `${formatSubagentTokenCount(usage.totalTokens)} tokens`, + ...(usage.toolUses === undefined + ? [] + : [`${usage.toolUses} ${usage.toolUses === 1 ? "tool" : "tools"}`]), + ].join(" · "); + return { + statusLabel: "Working", + activityLabel: agent.lastToolName === null ? null : `Last tool: ${agent.lastToolName}`, + usageLabel, + }; +} + /** * Reduces the wire snapshot to the only fields a client is allowed to render. * Native ids and any future envelope metadata never enter presentation state.