Skip to content

Commit 215771e

Browse files
committed
fix(prime): make child live activity truthful
1 parent 7fcb96c commit 215771e

12 files changed

Lines changed: 255 additions & 50 deletions

File tree

‎apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import {
33
presentSessionAgentLiveActivity,
4+
presentSessionAgentLiveActivityAgentSummary,
45
sessionAgentLiveActivityTextRows,
56
sessionAgentLiveActivityUnavailableLabel,
67
} from "@t3tools/client-runtime/state/session-agent-live-activity";
@@ -10,6 +11,7 @@ import {
1011
type ProviderSessionAgentActivitySnapshot,
1112
type ThreadId,
1213
} from "@t3tools/contracts";
14+
import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime";
1315
import * as Cause from "effect/Cause";
1416
import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
1517

@@ -20,11 +22,13 @@ export function SessionAgentLiveActivityModal({
2022
environmentId,
2123
threadId,
2224
agentId,
25+
agent,
2326
onClose,
2427
}: {
2528
readonly environmentId: EnvironmentId;
2629
readonly threadId: ThreadId;
2730
readonly agentId: string;
31+
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
2832
readonly onClose: () => void;
2933
}) {
3034
const result = useAtomValue(
@@ -71,7 +75,7 @@ export function SessionAgentLiveActivityModal({
7175
<Text className="text-sm text-foreground-muted">Loading live activity…</Text>
7276
</View>
7377
) : (
74-
<SessionAgentLiveActivitySnapshot snapshot={result.value} />
78+
<SessionAgentLiveActivitySnapshot snapshot={result.value} agent={agent} />
7579
)}
7680
</View>
7781
</View>
@@ -81,29 +85,52 @@ export function SessionAgentLiveActivityModal({
8185

8286
export function SessionAgentLiveActivitySnapshot({
8387
snapshot,
88+
agent,
8489
}: {
8590
readonly snapshot: ProviderSessionAgentActivitySnapshot;
91+
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
8692
}) {
8793
const presentation = presentSessionAgentLiveActivity(snapshot);
88-
if (presentation.entries.length === 0) {
89-
return (
90-
<Text accessibilityRole="text" className="py-6 text-sm text-foreground-muted">
91-
No assistant activity yet.
92-
</Text>
93-
);
94-
}
94+
const summary = presentSessionAgentLiveActivityAgentSummary(agent);
9595
return (
96-
<ScrollView accessibilityLiveRegion="polite" className="shrink">
97-
<View className="gap-3 py-2">
98-
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
99-
<Text key={entry.key} className="text-sm leading-6 text-foreground">
100-
{entry.text}
96+
<View className="shrink">
97+
<View
98+
accessibilityLiveRegion="polite"
99+
className="mb-3 rounded-xl border border-border bg-secondary px-3 py-2"
100+
>
101+
<Text className="text-sm font-t3-bold text-foreground">{summary.statusLabel}</Text>
102+
{summary.activityLabel === null ? null : (
103+
<Text className="mt-0.5 text-xs text-foreground-muted">{summary.activityLabel}</Text>
104+
)}
105+
{summary.usageLabel === null ? null : (
106+
<Text className="mt-0.5 font-mono text-xs text-foreground-muted">
107+
{summary.usageLabel}
101108
</Text>
102-
))}
109+
)}
103110
</View>
104-
<Text className="mt-2 border-t border-border pt-2 text-xs text-foreground-muted">
105-
Latest bounded snapshot · Live only
106-
</Text>
107-
</ScrollView>
111+
{presentation.entries.length === 0 ? (
112+
<View className="py-4">
113+
<Text accessibilityRole="text" className="text-sm text-foreground-muted">
114+
No assistant text yet.
115+
</Text>
116+
<Text className="mt-1 text-xs text-foreground-muted">
117+
Tool arguments, results, and reasoning are not shown.
118+
</Text>
119+
</View>
120+
) : (
121+
<ScrollView accessibilityLiveRegion="polite" className="shrink">
122+
<View className="gap-3 py-2">
123+
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
124+
<Text key={entry.key} className="text-sm leading-6 text-foreground">
125+
{entry.text}
126+
</Text>
127+
))}
128+
</View>
129+
<Text className="mt-2 border-t border-border pt-2 text-xs text-foreground-muted">
130+
Latest bounded snapshot · Live only
131+
</Text>
132+
</ScrollView>
133+
)}
134+
</View>
108135
);
109136
}

‎apps/mobile/src/features/threads/ThreadComposer.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,6 +2021,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
20212021
environmentId={props.environmentId}
20222022
threadId={props.selectedThread.id}
20232023
agentId={selectedLiveActivityAgent.id}
2024+
agent={selectedLiveActivityAgent}
20242025
onClose={() => setLiveActivitySelection(null)}
20252026
/>
20262027
) : null}

‎apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,25 @@ describe("Prime Agent live activity privacy boundary", () => {
22762276
expect(JSON.stringify(entries)).not.toContain("native-tool");
22772277
});
22782278

2279+
it("returns an empty snapshot for realistic thinking and tool-only activity", () => {
2280+
expect(
2281+
sanitizePrimeAgentLiveActivityMessages([
2282+
{
2283+
role: "assistant",
2284+
content: [
2285+
{ type: "thinking", thinking: "private reasoning" },
2286+
{ type: "toolCall", id: "native-tool", name: "ipython", arguments: { path: "/tmp" } },
2287+
],
2288+
},
2289+
{
2290+
role: "toolResult",
2291+
toolName: "ipython",
2292+
content: [{ type: "text", text: "private result" }],
2293+
},
2294+
]),
2295+
).toEqual([]);
2296+
});
2297+
22792298
it.effect("coalesces watcher events and closes the second connection when the stream ends", () =>
22802299
Effect.gen(function* () {
22812300
let messages: ReadonlyArray<unknown> = [
@@ -2412,6 +2431,72 @@ describe("Prime Agent live activity privacy boundary", () => {
24122431
),
24132432
);
24142433

2434+
it.effect("does not count invisible initialization events against the bounded buffer", () =>
2435+
Effect.scoped(
2436+
Effect.gen(function* () {
2437+
let markReadStarted!: () => void;
2438+
let resolveInitialRead!: (messages: ReadonlyArray<unknown>) => void;
2439+
const readStarted = new Promise<void>((resolve) => {
2440+
markReadStarted = resolve;
2441+
});
2442+
const initialRead = new Promise<ReadonlyArray<unknown>>((resolve) => {
2443+
resolveInitialRead = resolve;
2444+
});
2445+
const { emitWatch, make } = fixture({
2446+
getWatchMessages: () => {
2447+
markReadStarted();
2448+
return initialRead;
2449+
},
2450+
});
2451+
const runtime = yield* make();
2452+
const fiber = yield* runtime
2453+
.watchAgentActivity("native-child-active")
2454+
.pipe(Stream.take(2), Stream.runCollect, Effect.forkChild);
2455+
yield* Effect.promise(() => readStarted);
2456+
for (let index = 0; index < 128; index += 1) {
2457+
yield* Effect.promise(() =>
2458+
emitWatch({
2459+
type: "session_event",
2460+
event: {
2461+
type: "message_update",
2462+
message: {
2463+
role: "assistant",
2464+
content: [
2465+
{ type: "thinking", thinking: `private-${index}` },
2466+
{
2467+
type: "toolCall",
2468+
id: `native-${index}`,
2469+
name: "ipython",
2470+
arguments: { path: "/private/path" },
2471+
},
2472+
],
2473+
},
2474+
},
2475+
}),
2476+
);
2477+
}
2478+
yield* Effect.promise(() =>
2479+
emitWatch({
2480+
type: "session_event",
2481+
event: {
2482+
type: "message_end",
2483+
message: {
2484+
role: "assistant",
2485+
content: [{ type: "text", text: "visible answer" }],
2486+
},
2487+
},
2488+
}),
2489+
);
2490+
resolveInitialRead([]);
2491+
2492+
expect(Array.from(yield* Fiber.join(fiber))).toEqual([
2493+
[],
2494+
[{ speaker: "assistant", text: "visible answer" }],
2495+
]);
2496+
}),
2497+
),
2498+
);
2499+
24152500
it.effect("fails bounded initialization buffering instead of retaining unlimited events", () =>
24162501
Effect.scoped(
24172502
Effect.gen(function* () {

‎apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2121,13 +2121,15 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo
21212121
return undefined;
21222122
}
21232123
const visible = sanitizePrimeAgentLiveActivityMessages([nativeEvent.message]);
2124+
const message = visible[0];
2125+
// Tool/reasoning-only events cannot affect the public snapshot,
2126+
// so they must not consume the bounded initialization budget.
2127+
if (message === undefined) return undefined;
21242128
return {
21252129
type: "session_event",
21262130
event: {
21272131
type: nativeEvent.type,
2128-
...(visible[0] === undefined
2129-
? {}
2130-
: { message: safeAssistantMessage(visible[0].text) }),
2132+
message: safeAssistantMessage(message.text),
21312133
},
21322134
};
21332135
};
Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import {
33
presentSessionAgentLiveActivity,
4+
presentSessionAgentLiveActivityAgentSummary,
45
sessionAgentLiveActivityTextRows,
56
sessionAgentLiveActivityUnavailableLabel,
67
} from "@t3tools/client-runtime/state/session-agent-live-activity";
@@ -10,6 +11,7 @@ import {
1011
type ProviderSessionAgentActivitySnapshot,
1112
type ThreadId,
1213
} from "@t3tools/contracts";
14+
import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime";
1315
import * as Cause from "effect/Cause";
1416

1517
import { orchestrationEnvironment } from "~/state/orchestration";
@@ -18,10 +20,12 @@ export function AgentLiveActivity({
1820
environmentId,
1921
threadId,
2022
agentId,
23+
agent,
2124
}: {
2225
readonly environmentId: EnvironmentId;
2326
readonly threadId: ThreadId;
2427
readonly agentId: string;
28+
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
2529
}) {
2630
const result = useAtomValue(
2731
orchestrationEnvironment.sessionAgentLiveActivity({
@@ -45,40 +49,60 @@ export function AgentLiveActivity({
4549
);
4650
}
4751

48-
return <AgentLiveActivitySnapshot snapshot={result.value} />;
52+
return <AgentLiveActivitySnapshot snapshot={result.value} agent={agent} />;
4953
}
5054

5155
export function AgentLiveActivitySnapshot({
5256
snapshot,
57+
agent,
5358
}: {
5459
readonly snapshot: ProviderSessionAgentActivitySnapshot;
60+
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
5561
}) {
5662
const presentation = presentSessionAgentLiveActivity(snapshot);
57-
if (presentation.entries.length === 0) {
58-
return (
59-
<p role="status" className="p-4 text-sm text-muted-foreground">
60-
No assistant activity yet.
61-
</p>
62-
);
63-
}
63+
const summary = presentSessionAgentLiveActivityAgentSummary(agent);
6464

6565
return (
66-
<div className="max-h-[min(60vh,32rem)] overflow-y-auto px-4 py-3" aria-live="polite">
67-
<div className="space-y-3">
68-
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
69-
<p
70-
// Keys derive only from safe assistant text plus its occurrence
71-
// within this complete replacement snapshot.
72-
key={entry.key}
73-
className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"
74-
>
75-
{entry.text}
76-
</p>
77-
))}
66+
<div className="flex min-h-0 flex-1 flex-col">
67+
<div
68+
className="mx-4 mt-3 shrink-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2"
69+
aria-live="polite"
70+
>
71+
<p className="text-sm font-medium text-foreground">{summary.statusLabel}</p>
72+
{summary.activityLabel === null ? null : (
73+
<p className="mt-0.5 text-xs text-muted-foreground">{summary.activityLabel}</p>
74+
)}
75+
{summary.usageLabel === null ? null : (
76+
<p className="mt-0.5 font-mono text-xs text-muted-foreground">{summary.usageLabel}</p>
77+
)}
7878
</div>
79-
<p className="mt-4 border-t border-border/60 pt-2 text-xs text-muted-foreground">
80-
Latest bounded snapshot · Live only
81-
</p>
79+
{presentation.entries.length === 0 ? (
80+
<div role="status" className="p-4 text-sm text-muted-foreground">
81+
<p>No assistant text yet.</p>
82+
<p className="mt-1 text-xs">Tool arguments, results, and reasoning are not shown.</p>
83+
</div>
84+
) : (
85+
<div
86+
className="max-h-[min(60vh,32rem)] min-h-0 flex-1 overflow-y-auto px-4 py-3"
87+
aria-live="polite"
88+
>
89+
<div className="space-y-3">
90+
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
91+
<p
92+
// Keys derive only from safe assistant text plus its occurrence
93+
// within this complete replacement snapshot.
94+
key={entry.key}
95+
className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"
96+
>
97+
{entry.text}
98+
</p>
99+
))}
100+
</div>
101+
<p className="mt-4 border-t border-border/60 pt-2 text-xs text-muted-foreground">
102+
Latest bounded snapshot · Live only
103+
</p>
104+
</div>
105+
)}
82106
</div>
83107
);
84108
}

‎apps/web/src/components/AgentsPanel.test.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,26 @@ describe("AgentsPanel agent cancellation", () => {
137137
expect(gated).not.toContain("Live activity unavailable");
138138
});
139139

140-
it("renders empty and bounded assistant-only replacement snapshots", () => {
140+
it("renders safe aggregate status with empty and bounded assistant-only snapshots", () => {
141+
const liveAgent = {
142+
...active,
143+
lastToolName: "ipython",
144+
usage: { totalTokens: 65_800, toolUses: 14 },
145+
progress: "private progress",
146+
};
141147
const empty = renderToStaticMarkup(
142148
<AgentLiveActivitySnapshot
143149
snapshot={{ agentId: "canonical" as never, revision: 1, entries: [] }}
150+
agent={liveAgent}
144151
/>,
145152
);
146-
expect(empty).toContain("No assistant activity yet.");
153+
expect(empty).toContain("Working");
154+
expect(empty).toContain('aria-live="polite"');
155+
expect(empty).toContain("Last tool: ipython");
156+
expect(empty).toContain("65.8k tokens · 14 tools");
157+
expect(empty).toContain("No assistant text yet.");
158+
expect(empty).toContain("Tool arguments, results, and reasoning are not shown.");
159+
expect(empty).not.toContain("private progress");
147160

148161
const snapshot = {
149162
agentId: "canonical",
@@ -156,7 +169,9 @@ describe("AgentsPanel agent cancellation", () => {
156169
usage: "private usage",
157170
metadata: "private metadata",
158171
} as unknown as ProviderSessionAgentActivitySnapshot;
159-
const markup = renderToStaticMarkup(<AgentLiveActivitySnapshot snapshot={snapshot} />);
172+
const markup = renderToStaticMarkup(
173+
<AgentLiveActivitySnapshot snapshot={snapshot} agent={liveAgent} />,
174+
);
160175
expect(markup).toContain("Safe assistant update");
161176
expect(markup).toContain("Latest bounded snapshot · Live only");
162177
expect(markup).not.toContain("private-native-id");

‎apps/web/src/components/AgentsPanel.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,7 @@ export function AgentsPanel({
936936
environmentId={environmentId}
937937
threadId={threadId}
938938
agentId={selectedLiveActivityAgent.id}
939+
agent={selectedLiveActivityAgent}
939940
/>
940941
) : null}
941942
<DialogFooter>

0 commit comments

Comments
 (0)