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
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Platform, ScrollView, View, useColorScheme } from "react-native";
import {
Platform,
ScrollView,
type StyleProp,
type TextStyle,
View,
useColorScheme,
} from "react-native";

import { AppText as Text } from "../../../../components/AppText";
import {
Expand Down Expand Up @@ -54,14 +61,48 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number })
fontSize: props.fontSize,
lineHeight,
} as const;
// AppText stamps the sans font on every node, so nested spans must
// re-apply the terminal font instead of relying on inheritance, exactly
// like the code preview's tokens below.
const span = (color: string, extra?: TextStyle): StyleProp<TextStyle> => [
lineStyle,
{ color, ...extra },
];

return (
<View className="p-4">
<Text style={[lineStyle, { color: theme.foreground }]}>$ npm run dev</Text>
<Text style={[lineStyle, { color: theme.palette[2] }]}>✓ Ready in 430ms</Text>
<Text style={[lineStyle, { color: theme.foreground }]}>
Local: http://localhost:3000{" "}
<Text style={[lineStyle, { color: theme.cursorForeground }]}>▏</Text>
<Text style={span(theme.foreground)}>
<Text style={span(theme.palette[2])}>→ </Text>
<Text style={span(theme.palette[6])}>t3code </Text>
<Text style={span(theme.palette[4])}>git:(</Text>
<Text style={span(theme.palette[1])}>main</Text>
<Text style={span(theme.palette[4])}>)</Text>
<Text style={span(theme.palette[3])}> ✗</Text>
<Text style={span(theme.foreground)}> vpr dev</Text>
</Text>
<Text style={span(theme.foreground)}>
<Text style={span(theme.palette[2])}>VITE v7.1.1</Text>
<Text style={span(theme.mutedForeground)}> ready in</Text>
<Text style={span(theme.foreground)}> 1.24s</Text>
</Text>
<Text style={span(theme.foreground)}>
<Text style={span(theme.palette[2])}>→ </Text>
<Text style={span(theme.mutedForeground)}>Local: </Text>
<Text style={span(theme.palette[6], { textDecorationLine: "underline" })}>
http://127.0.0.1:5173/
</Text>
</Text>
<Text style={span(theme.foreground)}>
<Text style={span(theme.palette[2])}>✓ 85 passed</Text>
<Text style={span(theme.palette[3])}> △ 2 warnings</Text>
<Text style={span(theme.palette[1])}> ✗ 0 failed</Text>
</Text>
<Text style={span(theme.foreground)}>
<Text style={span(theme.background, { backgroundColor: theme.palette[2] })}>
{" READY "}
</Text>
<Text style={span(theme.mutedForeground)}> watching for changes</Text>{" "}
<Text style={span(theme.cursorForeground)}>▏</Text>
</Text>
</View>
);
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,39 @@ describe("promoteSteeredQueuedMessages", () => {
expect(promoteSteeredQueuedMessages(persisted, new Set([steeredId])).messages).toHaveLength(1);
});
});

describe("quiet timeline: nested agents", () => {
it("keeps a nested agent's terminal row but hides its background work", () => {
const thread = makeThread({
id: ThreadId.make("thread-nested"),
projectId: ProjectId.make("project-1"),
title: "Nested agents",
activities: [
// A subagent's own shell: internal, covered by the owner's liveness.
makeActivity({
id: EventId.make("shell-done"),
kind: "task.completed",
summary: "Task completed",
createdAt: "2026-04-01T00:00:02.000Z",
payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" },
}),
// A nested AGENT's completion: mobile has no Agents sheet, so this
// terminal row is the only signal it ever finished.
makeActivity({
id: EventId.make("nested-done"),
kind: "task.completed",
summary: "Task completed",
createdAt: "2026-04-01T00:00:03.000Z",
payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" },
}),
],
});

const feed = buildThreadFeed(thread);
const ids = feed.flatMap((entry) =>
entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
);
expect(ids).toContain("nested-done");
expect(ids).not.toContain("shell-done");
});
});
94 changes: 93 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ interface WorkLogEntry {
interface DerivedWorkLogEntry extends WorkLogEntry {
activityKind: OrchestrationThreadActivity["kind"];
collapseKey?: string;
/** Grouping key for subagent lifecycle rows (one row per agent). */
taskId?: string;
}

type RawThreadFeedEntry =
Expand Down Expand Up @@ -302,6 +304,63 @@ function resolvePendingUserInputAnswer(
return normalizeDraftAnswer(draft?.selectedOptionLabel);
}

/** Codex children settle via task.updated (idle/failed/interrupted), never
* task.completed — these rows are mobile's only terminal signal for them. */
const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
"idle",
"completed",
"failed",
"cancelled",
"interrupted",
]);

function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "task.updated") {
return false;
}
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
return (
payload?.timelineBypass === true &&
typeof payload.status === "string" &&
MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)
);
}

/**
* Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
* activity lives in the Agents sheet, not the work log. Terminal rows are
* kept — with no Agents surface on mobile they are the terminal signal
* (a surface that hides rows must keep its own terminal signal). That means
* task.completed (Claude) AND terminal bypassed task.updated (Codex, whose
* children never emit task.completed — review finding).
*/
function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
if (!payload) {
return false;
}
const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity);
if (payload.timelineBypass === true && !isTerminalTaskRow) {
return true;
}
// agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
// the only signal mobile gets (no Agents sheet), so it stays. Only an
// agent's own background work (stamped "background") is internal — same
// rule as web (review finding: hiding on agentId alone dropped nested
// completions with no replacement UI).
const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
if (!ownedByAgent) {
return false;
}
return !(isTerminalTaskRow && payload.agentKind === "agent");
}

function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): DerivedWorkLogEntry[] {
Expand All @@ -313,9 +372,13 @@ function deriveWorkLogEntries(
for (const activity of ordered) {
if (activity.kind === "tool.started") continue;
if (activity.kind === "task.started") continue;
// Terminal bypassed updates pass: Codex children's only terminal signal.
if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
if (activity.summary === "Checkpoint captured") continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
const entry = toDerivedWorkLogEntry(activity);
const resolvedUserInput = resolvedUserInputs.get(activity.id);
if (resolvedUserInput) {
Expand Down Expand Up @@ -347,7 +410,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
// task.updated included: terminal bypassed updates (Codex children's only
// terminal signal) must carry task identity so they collapse per child
// instead of stacking anonymous "Task idle" rows.
const isTaskActivity =
activity.kind === "task.progress" ||
activity.kind === "task.completed" ||
activity.kind === "task.updated";
const taskSummary =
isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
? payload.summary
Expand All @@ -360,10 +429,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
? payload.detail
: null;
const taskLabel = taskSummary || taskDetailAsLabel;
const taskId =
isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
? payload.taskId
: undefined;
const entry: DerivedWorkLogEntry = {
id: activity.id,
createdAt: activity.createdAt,
turnId: activity.turnId,
...(taskId ? { taskId } : {}),
label: taskLabel || activity.summary,
tone:
activity.kind === "task.progress"
Expand Down Expand Up @@ -428,7 +502,25 @@ function collapseDerivedWorkLogEntries(
entries: ReadonlyArray<DerivedWorkLogEntry>,
): DerivedWorkLogEntry[] {
const collapsed: DerivedWorkLogEntry[] = [];
// Subagent rows collapse by identity, not adjacency (quiet-timeline
// guarantee; mirrors web's session-logic).
const taskRowIndex = new Map<string, number>();
for (const entry of entries) {
const isTaskRow =
entry.taskId !== undefined &&
(entry.activityKind === "task.progress" ||
entry.activityKind === "task.completed" ||
entry.activityKind === "task.updated");
if (isTaskRow && entry.taskId !== undefined) {
const existingIndex = taskRowIndex.get(entry.taskId);
if (existingIndex !== undefined) {
collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
continue;
}
taskRowIndex.set(entry.taskId, collapsed.length);
collapsed.push(entry);
continue;
}
const previous = collapsed.at(-1);
if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe
import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
Expand Down Expand Up @@ -307,7 +308,7 @@ export const makeOrchestrationIntegrationHarness = (
providerLayer,
providerSessionDirectoryLayer,
RuntimeReceiptBusTest,
);
).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
const serverSettingsLayer = ServerSettingsService.layerTest();
const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
*/
export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
[ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getThreadActivities]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vite-plus/test";
import type { OrchestrationThreadActivity } from "@t3tools/contracts";
import { projectActivityPayload } from "./ActivityPayloadProjection.ts";

function activity(payload: Record<string, unknown>): OrchestrationThreadActivity {
return {
id: "activity-1",
tone: "tool",
kind: "tool.completed",
summary: "Tool",
payload,
turnId: null,
createdAt: "2026-08-01T10:00:00.000Z",
} as unknown as OrchestrationThreadActivity;
}

/**
* Wire-survival regression: the slimming pass rewrites payload.data but must
* never strip the top-level per-agent fields the subagent fold depends on.
* If slimming ever moves to an allowlist over the whole payload, these
* assertions are the tripwire.
*/
describe("projectActivityPayload agent-field survival", () => {
it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
const projected = projectActivityPayload(
activity({
itemType: "command_execution",
agentId: "task-123",
parentToolUseId: "toolu_abc",
data: {
toolName: "Bash",
input: { command: "ls" },
command: "ls",
rawOutput: { content: "x".repeat(10) },
somethingClientNeverReads: { big: "blob" },
},
}),
);
const payload = projected.payload as Record<string, unknown>;
expect(payload.agentId).toBe("task-123");
expect(payload.parentToolUseId).toBe("toolu_abc");
// Slimming itself still applies to data.
const data = payload.data as Record<string, unknown>;
expect(data.somethingClientNeverReads).toBeUndefined();
});

it("passes task lifecycle payloads (no data field) through untouched", () => {
const source = activity({
taskId: "task-9",
title: "Audit auth",
role: "explorer",
model: "opus",
effort: "high",
workflowName: "audit-flow",
phases: [{ index: 0, title: "Audit" }],
typedUsage: { totalTokens: 1200 },
runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" },
timelineBypass: true,
});
const projected = projectActivityPayload(source);
expect(projected.payload).toEqual(source.payload);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
Expand Down Expand Up @@ -295,13 +296,15 @@ describe("CheckpointReactor", () => {
);
const orchestrationLayer = OrchestrationEngineLive.pipe(
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
Expand Down
Loading
Loading