1import { EMPTY_V2_ITEM_SUPPORT } from "@t3tools/client-runtime/state/item-support";
2import {
3 CheckpointId,
4 CheckpointScopeId,
5 NodeId,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 RunId,
9 ThreadId,
10 TurnItemId,
11 type OrchestrationV2ProjectedTurnItem,
12 type OrchestrationV2TurnItem,
13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
15import { describe, expect, it } from "vite-plus/test";
16
17import type { ThreadFeedActivity } from "./threadActivity";
18import { buildThreadActivityInspector } from "./threadActivityInspector";
19
20const threadId = ThreadId.make("thread-1");
21const sourceThreadId = ThreadId.make("thread-source");
22const runId = RunId.make("run-1");
23const nodeId = NodeId.make("node-1");
24const startedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
25const completedAt = DateTime.makeUnsafe("2026-06-20T00:00:02.000Z");
26
27function itemBase(id: string) {
28 return {
29 id: TurnItemId.make(id),
30 threadId,
31 runId,
32 nodeId,
33 providerThreadId: null,
34 providerTurnId: null,
35 nativeItemRef: null,
36 parentItemId: null,
37 ordinal: 0,
38 status: "completed" as const,
39 title: null,
40 startedAt,
41 completedAt,
42 updatedAt: completedAt,
43 };
44}
45
46function activityFor(item: OrchestrationV2TurnItem): Pick<ThreadFeedActivity, "projectedItem"> {
47 const row: OrchestrationV2ProjectedTurnItem = {
48 position: 0,
49 visibility: "inherited",
50 sourceThreadId,
51 sourceItemId: item.id,
52 item,
53 };
54 return { projectedItem: row };
55}
56
57describe("buildThreadActivityInspector", () => {
58 it("presents command lifecycle and exit state without cached raw output", () => {
59 const item: OrchestrationV2TurnItem = {
60 ...itemBase("command"),
61 type: "command_execution",
62 input: "vp check",
63 output: "all checks passed",
64 exitCode: 0,
65 };
66 const support = {
67 ...EMPTY_V2_ITEM_SUPPORT,
68 item,
69 run: { status: "completed" } as never,
70 attempts: [
71 {
72 id: "attempt-1",
73 attemptOrdinal: 1,
74 status: "superseded",
75 reason: "initial",
76 },
77 {
78 id: "attempt-2",
79 attemptOrdinal: 2,
80 status: "completed",
81 reason: "steering_restart",
82 },
83 ] as never,
84 node: { kind: "tool_call", status: "completed" } as never,
85 providerSession: {
86 status: "ready",
87 model: "gpt-5.4",
88 cwd: "/workspace/project",
89 } as never,
90 };
91
92 const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId);
93 expect(model.fields).toEqual(
94 expect.arrayContaining([
95 { label: "Duration", value: "2.0s" },
96 { label: "Run", value: "completed" },
97 { label: "Working directory", value: "/workspace/project" },
98 { label: "Visibility", value: "inherited" },
99 ]),
100 );
101 expect(model.blocks).toEqual(
102 expect.arrayContaining([
103 expect.objectContaining({ label: "Command", value: "vp check" }),
104 expect.objectContaining({ label: "Exit", value: "Process exited with code 0" }),
105 expect.objectContaining({ label: "Attempt history" }),
106 ]),
107 );
108 expect(model.blocks.some((block) => block.label === "Output")).toBe(false);
109 expect(model.structuredDetails).not.toContain("all checks passed");
110 });
111
112 it("exposes file and web result provenance plus dynamic structured data", () => {
113 const fileSearch: OrchestrationV2TurnItem = {
114 ...itemBase("file-search"),
115 type: "file_search",
116 pattern: "resolveSession",
117 results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }],
118 };
119 const fileModel = buildThreadActivityInspector(
120 activityFor(fileSearch),
121 EMPTY_V2_ITEM_SUPPORT,
122 sourceThreadId,
123 );
124 expect(fileModel.fileLinks).toEqual([
125 {
126 label: "src/session.ts — function resolveSession()",
127 path: "src/session.ts",
128 line: 42,
129 },
130 ]);
131
132 const webSearch: OrchestrationV2TurnItem = {
133 ...itemBase("web-search"),
134 type: "web_search",
135 patterns: ["Effect Schema docs"],
136 results: [
137 { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" },
138 ],
139 };
140 const webModel = buildThreadActivityInspector(
141 activityFor(webSearch),
142 EMPTY_V2_ITEM_SUPPORT,
143 sourceThreadId,
144 );
145 expect(webModel.webLinks).toEqual([
146 { label: "Schema", url: "https://effect.website/docs/schema" },
147 ]);
148 expect(webModel.blocks).toEqual(
149 expect.arrayContaining([
150 expect.objectContaining({ label: "Schema", value: "Typed schemas" }),
151 ]),
152 );
153
154 const dynamicTool: OrchestrationV2TurnItem = {
155 ...itemBase("dynamic"),
156 type: "dynamic_tool",
157 toolName: "custom",
158 input: { nested: { value: 1 } },
159 output: { ok: true },
160 };
161 const dynamicModel = buildThreadActivityInspector(
162 activityFor(dynamicTool),
163 EMPTY_V2_ITEM_SUPPORT,
164 sourceThreadId,
165 );
166 expect(dynamicModel.blocks).toEqual(
167 expect.arrayContaining([
168 expect.objectContaining({ label: "Input", value: expect.stringContaining('"nested"') }),
169 ]),
170 );
171 expect(dynamicModel.structuredDetails).toContain('"type": "dynamic_tool"');
172 expect(dynamicModel.blocks.some((block) => block.label === "Output")).toBe(false);
173 expect(dynamicModel.structuredDetails).not.toContain('"output"');
174 });
175
176 it("keeps file links and change counts without cached patch bodies", () => {
177 const item: OrchestrationV2TurnItem = {
178 ...itemBase("file"),
179 type: "file_change",
180 fileName: "src/example.ts",
181 additions: 2,
182 deletions: 1,
183 diffStr: "RAW_PATCH",
184 oldStr: "RAW_BEFORE",
185 newStr: "RAW_AFTER",
186 };
187 const model = buildThreadActivityInspector(activityFor(item), EMPTY_V2_ITEM_SUPPORT, threadId);
188 expect(model.fileLinks).toEqual([{ label: `modify ${item.fileName}`, path: item.fileName }]);
189 expect(model.fields).toContainEqual({ label: "Changes", value: "+2 −1" });
190 expect(model.blocks).toEqual([]);
191 expect(model.structuredDetails).not.toContain("RAW_");
192 expect(model.structuredDetails).toContain(item.fileName);
193 });
194
195 it("enables rollback only for the matching ready checkpoint", () => {
196 const checkpointId = CheckpointId.make("checkpoint-1");
197 const scopeId = CheckpointScopeId.make("scope-1");
198 const item: OrchestrationV2TurnItem = {
199 ...itemBase("checkpoint"),
200 type: "checkpoint",
201 checkpointId,
202 scopeId,
203 files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }],
204 };
205 const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never;
206 const model = buildThreadActivityInspector(
207 activityFor(item),
208 {
209 ...EMPTY_V2_ITEM_SUPPORT,
210 item,
211 checkpoint,
212 },
213 sourceThreadId,
214 );
215
216 expect(model.canRollback).toBe(true);
217 expect(model.rollbackTarget).toEqual({
218 threadId: sourceThreadId,
219 checkpointId,
220 scopeId,
221 });
222
223 const inheritedModel = buildThreadActivityInspector(
224 activityFor(item),
225 {
226 ...EMPTY_V2_ITEM_SUPPORT,
227 item,
228 checkpoint,
229 },
230 ThreadId.make("child-thread"),
231 );
232 expect(inheritedModel.canRollback).toBe(false);
233 expect(inheritedModel.rollbackTarget).toBeNull();
234 });
235
236 it("prefers live subagent progress from supporting state", () => {
237 const item: OrchestrationV2TurnItem = {
238 ...itemBase("subagent"),
239 type: "subagent",
240 subagentId: nodeId,
241 origin: "provider_native",
242 driver: ProviderDriverKind.make("claude"),
243 providerInstanceId: ProviderInstanceId.make("claude"),
244 childThreadId: null,
245 prompt: "Audit the reducer",
246 progress: "Starting",
247 result: null,
248 };
249 const model = buildThreadActivityInspector(
250 activityFor(item),
251 {
252 ...EMPTY_V2_ITEM_SUPPORT,
253 item,
254 subagent: {
255 origin: "provider_native",
256 status: "running",
257 progress: "Reading projection tests",
258 result: null,
259 } as never,
260 },
261 sourceThreadId,
262 );
263
264 expect(model.fields).toContainEqual({
265 label: "Delegated task",
266 value: "provider native · running",
267 });
268 expect(model.blocks).toContainEqual({
269 label: "Progress",
270 value: "Reading projection tests",
271 monospaced: false,
272 });
273 });
274});