Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 45 additions & 18 deletions apps/mobile/src/features/threads/SessionAgentLiveActivityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAtomValue } from "@effect/atom-react";
import {
presentSessionAgentLiveActivity,
presentSessionAgentLiveActivityAgentSummary,
sessionAgentLiveActivityTextRows,
sessionAgentLiveActivityUnavailableLabel,
} from "@t3tools/client-runtime/state/session-agent-live-activity";
Expand All @@ -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";

Expand All @@ -20,11 +22,13 @@ export function SessionAgentLiveActivityModal({
environmentId,
threadId,
agentId,
agent,
onClose,
}: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly agentId: string;
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
readonly onClose: () => void;
}) {
const result = useAtomValue(
Expand Down Expand Up @@ -71,7 +75,7 @@ export function SessionAgentLiveActivityModal({
<Text className="text-sm text-foreground-muted">Loading live activity…</Text>
</View>
) : (
<SessionAgentLiveActivitySnapshot snapshot={result.value} />
<SessionAgentLiveActivitySnapshot snapshot={result.value} agent={agent} />
)}
</View>
</View>
Expand All @@ -81,29 +85,52 @@ export function SessionAgentLiveActivityModal({

export function SessionAgentLiveActivitySnapshot({
snapshot,
agent,
}: {
readonly snapshot: ProviderSessionAgentActivitySnapshot;
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
}) {
const presentation = presentSessionAgentLiveActivity(snapshot);
if (presentation.entries.length === 0) {
return (
<Text accessibilityRole="text" className="py-6 text-sm text-foreground-muted">
No assistant activity yet.
</Text>
);
}
const summary = presentSessionAgentLiveActivityAgentSummary(agent);
return (
<ScrollView accessibilityLiveRegion="polite" className="shrink">
<View className="gap-3 py-2">
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
<Text key={entry.key} className="text-sm leading-6 text-foreground">
{entry.text}
<View className="shrink">
<View
accessibilityLiveRegion="polite"
className="mb-3 rounded-xl border border-border bg-secondary px-3 py-2"
>
<Text className="text-sm font-t3-bold text-foreground">{summary.statusLabel}</Text>
{summary.activityLabel === null ? null : (
<Text className="mt-0.5 text-xs text-foreground-muted">{summary.activityLabel}</Text>
)}
{summary.usageLabel === null ? null : (
<Text className="mt-0.5 font-mono text-xs text-foreground-muted">
{summary.usageLabel}
</Text>
))}
)}
</View>
<Text className="mt-2 border-t border-border pt-2 text-xs text-foreground-muted">
Latest bounded snapshot · Live only
</Text>
</ScrollView>
{presentation.entries.length === 0 ? (
<View className="py-4">
<Text accessibilityRole="text" className="text-sm text-foreground-muted">
No assistant text yet.
</Text>
<Text className="mt-1 text-xs text-foreground-muted">
Tool arguments, results, and reasoning are not shown.
</Text>
</View>
) : (
<ScrollView accessibilityLiveRegion="polite" className="shrink">
<View className="gap-3 py-2">
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
<Text key={entry.key} className="text-sm leading-6 text-foreground">
{entry.text}
</Text>
))}
</View>
<Text className="mt-2 border-t border-border pt-2 text-xs text-foreground-muted">
Latest bounded snapshot · Live only
</Text>
</ScrollView>
)}
</View>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = [
Expand Down Expand Up @@ -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<unknown>) => void;
const readStarted = new Promise<void>((resolve) => {
markReadStarted = resolve;
});
const initialRead = new Promise<ReadonlyArray<unknown>>((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* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
};
};
Expand Down
70 changes: 47 additions & 23 deletions apps/web/src/components/AgentLiveActivity.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAtomValue } from "@effect/atom-react";
import {
presentSessionAgentLiveActivity,
presentSessionAgentLiveActivityAgentSummary,
sessionAgentLiveActivityTextRows,
sessionAgentLiveActivityUnavailableLabel,
} from "@t3tools/client-runtime/state/session-agent-live-activity";
Expand All @@ -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";
Expand All @@ -18,10 +20,12 @@ export function AgentLiveActivity({
environmentId,
threadId,
agentId,
agent,
}: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly agentId: string;
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
}) {
const result = useAtomValue(
orchestrationEnvironment.sessionAgentLiveActivity({
Expand All @@ -45,40 +49,60 @@ export function AgentLiveActivity({
);
}

return <AgentLiveActivitySnapshot snapshot={result.value} />;
return <AgentLiveActivitySnapshot snapshot={result.value} agent={agent} />;
}

export function AgentLiveActivitySnapshot({
snapshot,
agent,
}: {
readonly snapshot: ProviderSessionAgentActivitySnapshot;
readonly agent: Pick<RuntimeSubagent, "lastToolName" | "usage">;
}) {
const presentation = presentSessionAgentLiveActivity(snapshot);
if (presentation.entries.length === 0) {
return (
<p role="status" className="p-4 text-sm text-muted-foreground">
No assistant activity yet.
</p>
);
}
const summary = presentSessionAgentLiveActivityAgentSummary(agent);

return (
<div className="max-h-[min(60vh,32rem)] overflow-y-auto px-4 py-3" aria-live="polite">
<div className="space-y-3">
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
<p
// Keys derive only from safe assistant text plus its occurrence
// within this complete replacement snapshot.
key={entry.key}
className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"
>
{entry.text}
</p>
))}
<div className="flex min-h-0 flex-1 flex-col">
<div
className="mx-4 mt-3 shrink-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2"
aria-live="polite"
>
<p className="text-sm font-medium text-foreground">{summary.statusLabel}</p>
{summary.activityLabel === null ? null : (
<p className="mt-0.5 text-xs text-muted-foreground">{summary.activityLabel}</p>
)}
{summary.usageLabel === null ? null : (
<p className="mt-0.5 font-mono text-xs text-muted-foreground">{summary.usageLabel}</p>
)}
</div>
<p className="mt-4 border-t border-border/60 pt-2 text-xs text-muted-foreground">
Latest bounded snapshot · Live only
</p>
{presentation.entries.length === 0 ? (
<div role="status" className="p-4 text-sm text-muted-foreground">
<p>No assistant text yet.</p>
<p className="mt-1 text-xs">Tool arguments, results, and reasoning are not shown.</p>
</div>
) : (
<div
className="max-h-[min(60vh,32rem)] min-h-0 flex-1 overflow-y-auto px-4 py-3"
aria-live="polite"
>
<div className="space-y-3">
{sessionAgentLiveActivityTextRows(presentation.entries).map((entry) => (
<p
// Keys derive only from safe assistant text plus its occurrence
// within this complete replacement snapshot.
key={entry.key}
className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground"
>
{entry.text}
</p>
))}
</div>
<p className="mt-4 border-t border-border/60 pt-2 text-xs text-muted-foreground">
Latest bounded snapshot · Live only
</p>
</div>
)}
</div>
);
}
21 changes: 18 additions & 3 deletions apps/web/src/components/AgentsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AgentLiveActivitySnapshot
snapshot={{ agentId: "canonical" as never, revision: 1, entries: [] }}
agent={liveAgent}
/>,
);
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",
Expand All @@ -156,7 +169,9 @@ describe("AgentsPanel agent cancellation", () => {
usage: "private usage",
metadata: "private metadata",
} as unknown as ProviderSessionAgentActivitySnapshot;
const markup = renderToStaticMarkup(<AgentLiveActivitySnapshot snapshot={snapshot} />);
const markup = renderToStaticMarkup(
<AgentLiveActivitySnapshot snapshot={snapshot} agent={liveAgent} />,
);
expect(markup).toContain("Safe assistant update");
expect(markup).toContain("Latest bounded snapshot · Live only");
expect(markup).not.toContain("private-native-id");
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ export function AgentsPanel({
environmentId={environmentId}
threadId={threadId}
agentId={selectedLiveActivityAgent.id}
agent={selectedLiveActivityAgent}
/>
) : null}
<DialogFooter>
Expand Down
Loading
Loading