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
84 changes: 84 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,90 @@ describe("buildThreadFeed", () => {
);
});

it("collapses a setup run into its latest state across interleaved activity", () => {
const thread = makeThread({
id: ThreadId.make("thread-setup-collapse"),
projectId: ProjectId.make("project-1"),
title: "Setup lifecycle",
activities: [
makeActivity({
id: EventId.make("setup-requested"),
kind: "setup-script.requested",
summary: "Starting setup script",
createdAt: "2026-04-01T00:00:01.000Z",
payload: { runId: "setup-run-1" },
}),
makeActivity({
id: EventId.make("unrelated-work"),
kind: "runtime.info",
summary: "Created worktree",
createdAt: "2026-04-01T00:00:02.000Z",
}),
makeActivity({
id: EventId.make("setup-started"),
kind: "setup-script.started",
summary: "Setup script started",
createdAt: "2026-04-01T00:00:03.000Z",
payload: { runId: "setup-run-1" },
}),
makeActivity({
id: EventId.make("setup-failed"),
kind: "setup-script.failed",
tone: "error",
summary: "Setup script failed",
createdAt: "2026-04-01T00:00:04.000Z",
payload: { runId: "setup-run-1", exitCode: 1 },
}),
],
});

const activities = buildThreadFeed(thread).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);

expect(activities).toHaveLength(2);
expect(activities[0]).toMatchObject({
id: "setup-requested",
createdAt: "2026-04-01T00:00:01.000Z",
summary: "Setup script failed",
status: "failure",
});
expect(activities[1]?.summary).toBe("Created worktree");
});

it("keeps separate setup runs and preserves completed labels", () => {
const thread = makeThread({
id: ThreadId.make("thread-separate-setup-runs"),
projectId: ProjectId.make("project-1"),
title: "Separate setup runs",
activities: [
makeActivity({
id: EventId.make("setup-one"),
kind: "setup-script.completed",
summary: "Setup script completed",
createdAt: "2026-04-01T00:00:01.000Z",
payload: { runId: "setup-run-1" },
}),
makeActivity({
id: EventId.make("setup-two"),
kind: "setup-script.completed",
summary: "Setup script completed",
createdAt: "2026-04-01T00:00:02.000Z",
payload: { runId: "setup-run-2" },
}),
],
});

const activities = buildThreadFeed(thread).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);

expect(activities.map((activity) => activity.summary)).toEqual([
"Setup script completed",
"Setup script completed",
]);
});

it("keeps MCP inputs available to expanded mobile work rows", () => {
const turnId = TurnId.make("turn-mcp");
const thread = makeThread({
Expand Down
29 changes: 28 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ interface WorkLogEntry {
interface DerivedWorkLogEntry extends WorkLogEntry {
activityKind: OrchestrationThreadActivity["kind"];
collapseKey?: string;
setupRunId?: string;
/** Grouping key for subagent lifecycle rows (one row per agent). */
taskId?: string;
}
Expand Down Expand Up @@ -408,6 +409,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (requestKind) {
entry.requestKind = requestKind;
}
if (activity.kind.startsWith("setup-script.") && typeof payload?.runId === "string") {
entry.setupRunId = payload.runId;
}
let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
if (!toolLifecycleStatus && activity.kind === "tool.completed") {
toolLifecycleStatus = "completed";
Expand All @@ -426,10 +430,28 @@ function collapseDerivedWorkLogEntries(
entries: ReadonlyArray<DerivedWorkLogEntry>,
): DerivedWorkLogEntry[] {
const collapsed: DerivedWorkLogEntry[] = [];
const setupRowIndex = new Map<string, number>();
// 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) {
if (entry.setupRunId !== undefined) {
const existingIndex = setupRowIndex.get(entry.setupRunId);
if (existingIndex !== undefined) {
const existing = collapsed[existingIndex]!;
collapsed[existingIndex] = {
...mergeDerivedWorkLogEntries(existing, entry),
id: existing.id,
createdAt: existing.createdAt,
turnId: existing.turnId,
setupRunId: entry.setupRunId,
};
continue;
}
setupRowIndex.set(entry.setupRunId, collapsed.length);
collapsed.push(entry);
continue;
}
const isTaskRow =
entry.taskId !== undefined &&
(entry.activityKind === "task.progress" ||
Expand Down Expand Up @@ -485,6 +507,7 @@ function mergeDerivedWorkLogEntries(
const collapseKey = next.collapseKey ?? previous.collapseKey;
const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
const toolData = next.toolData ?? previous.toolData;
const setupRunId = next.setupRunId ?? previous.setupRunId;
return {
...previous,
...next,
Expand All @@ -498,6 +521,7 @@ function mergeDerivedWorkLogEntries(
...(collapseKey ? { collapseKey } : {}),
...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
...(toolData !== undefined ? { toolData } : {}),
...(setupRunId !== undefined ? { setupRunId } : {}),
};
}

Expand Down Expand Up @@ -689,7 +713,10 @@ function capitalizePhrase(value: string): string {
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}

function workEntryHeading(workEntry: WorkLogEntry): string {
function workEntryHeading(workEntry: DerivedWorkLogEntry): string {
if (workEntry.activityKind.startsWith("setup-script.")) {
return capitalizePhrase(workEntry.label);
}
if (!workEntry.toolTitle) {
return capitalizePhrase(normalizeCompactToolLabel(workEntry.label));
}
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/observability/Metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total"
description: "Total terminal restart requests handled.",
});

export const setupScriptRunsTotal = Metric.counter("t3_setup_script_runs_total", {
description: "Total setup script runs by terminal outcome.",
});

export const setupScriptDuration = Metric.timer("t3_setup_script_duration", {
description: "Setup script run duration.",
});

export const metricAttributes = (
attributes: Readonly<Record<string, unknown>>,
): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,54 @@ describe("ProviderCommandReactor", () => {
});
});

it("keeps setup-script work-log activities out of provider input", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

await harness.runEffect(
harness.engine.dispatch({
type: "thread.activity.append",
commandId: CommandId.make("cmd-setup-script-started"),
threadId: ThreadId.make("thread-1"),
activity: {
id: EventId.make("activity-setup-script-started"),
tone: "info",
kind: "setup-script.started",
summary: "Setup script started",
payload: {
runId: "setup-run-1",
command: "bun install",
},
turnId: null,
createdAt: now,
},
createdAt: now,
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-after-setup-activity"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-after-setup-activity"),
role: "user",
text: "Implement the requested change.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.sendTurn.mock.calls.length === 1);
const request = harness.sendTurn.mock.calls[0]?.[0];
expect(request).toMatchObject({ input: "Implement the requested change." });
expect(JSON.stringify(request)).not.toContain("Setup script started");
expect(JSON.stringify(request)).not.toContain("bun install");
});

it("forwards claude effort options through session start and turn send", async () => {
const harness = await createHarness({
threadModelSelection: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { assert, it } from "@effect/vitest";
import { EventId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts";
import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts";
import { SqlitePersistenceMemory } from "./Sqlite.ts";

const layer = it.layer(
ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
);

layer("ProjectionThreadActivityRepository", (it) => {
it.effect("lists requested and started setup lifecycle rows without a persisted outcome", () =>
Effect.gen(function* () {
const repository = yield* ProjectionThreadActivityRepository;
const threadId = ThreadId.make("thread-setup-recovery");
const payload = {
runId: "run-1",
scriptId: "setup",
scriptName: "Setup",
command: "bun install",
terminalId: "setup-setup",
worktreePath: "/repo/worktree",
};
const append = (id: string, kind: string, sequence: number) =>
repository.upsert({
activityId: EventId.make(id),
threadId,
turnId: null,
tone: "info",
kind,
summary: kind,
payload,
sequence,
createdAt: `2026-01-01T00:00:0${sequence}.000Z`,
});

yield* append("requested", "setup-script.requested", 1);
yield* append("started", "setup-script.started", 2);
yield* append("unrelated", "file-edit", 3);
yield* append("completed", "setup-script.completed", 4);
yield* repository.upsert({
activityId: EventId.make("unfinished-requested"),
threadId,
turnId: null,
tone: "info",
kind: "setup-script.requested",
summary: "setup-script.requested",
payload: { ...payload, runId: "run-2" },
sequence: 5,
createdAt: "2026-01-01T00:00:05.000Z",
});
yield* repository.upsert({
activityId: EventId.make("unfinished-requested-before-start"),
threadId,
turnId: null,
tone: "info",
kind: "setup-script.requested",
summary: "setup-script.requested",
payload: { ...payload, runId: "run-3" },
sequence: 6,
createdAt: "2026-01-01T00:00:06.000Z",
});
yield* repository.upsert({
activityId: EventId.make("unfinished-started"),
threadId,
turnId: null,
tone: "info",
kind: "setup-script.started",
summary: "setup-script.started",
payload: { ...payload, runId: "run-3" },
sequence: 7,
createdAt: "2026-01-01T00:00:07.000Z",
});

const rows = yield* repository.listUnfinishedSetupRuns();
assert.deepEqual(
rows.map((row) => row.activityId),
[
EventId.make("unfinished-requested"),
EventId.make("unfinished-requested-before-start"),
EventId.make("unfinished-started"),
],
);
}),
);
});
Loading
Loading