From 5277734c67788da7bf50a28ec2b2f329198a7a1e Mon Sep 17 00:00:00 2001 From: AKolenda <91154044+AKolenda@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:12:07 -0600 Subject: [PATCH 1/4] fix(agents): retrieve recent tools and saved child activity --- ...ProviderSessionStartup.integration.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 45 + .../src/provider/Layers/ClaudeAdapter.ts | 36 + .../src/provider/Layers/CodexAdapter.ts | 48 + .../src/provider/Layers/GrokAdapter.test.ts | 109 ++ .../server/src/provider/Layers/GrokAdapter.ts | 77 ++ .../provider/Layers/OpenCodeAdapter.test.ts | 192 +++ .../src/provider/Layers/OpenCodeAdapter.ts | 176 +++ .../provider/Layers/ProviderService.test.ts | 67 ++ .../src/provider/Layers/ProviderService.ts | 31 + .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Layers/agentHistory.test.ts | 45 + .../src/provider/Layers/agentHistory.ts | 66 + .../Layers/claudeAgentHistory.test.ts | 142 +++ .../src/provider/Layers/claudeAgentHistory.ts | 222 ++++ .../provider/Layers/codexAgentHistory.test.ts | 118 ++ .../src/provider/Layers/codexAgentHistory.ts | 134 +++ .../provider/Layers/grokAgentHistory.test.ts | 150 +++ .../src/provider/Layers/grokAgentHistory.ts | 208 ++++ .../Layers/openCodeAgentHistory.test.ts | 110 ++ .../provider/Layers/openCodeAgentHistory.ts | 83 ++ .../src/provider/Services/ProviderAdapter.ts | 14 +- .../src/provider/Services/ProviderService.ts | 11 +- .../serverRuntimeStartup.reconcile.test.ts | 1 + apps/server/src/ws.ts | 14 + .../src/components/AgentsPanel.logic.test.ts | 110 ++ apps/web/src/components/AgentsPanel.logic.ts | 52 + apps/web/src/components/AgentsPanel.tsx | 1061 +++++++++++------ apps/web/src/components/ChatView.tsx | 2 + apps/web/src/session-logic.test.ts | 101 ++ apps/web/src/session-logic.ts | 30 + docs/user/providers-claude.md | 9 + docs/user/providers-codex.md | 10 + docs/user/providers-opencode.md | 11 + .../client-runtime/src/state/orchestration.ts | 6 + packages/contracts/src/orchestration.ts | 37 + packages/contracts/src/rpc.ts | 8 + 41 files changed, 3170 insertions(+), 372 deletions(-) create mode 100644 apps/server/src/provider/Layers/agentHistory.test.ts create mode 100644 apps/server/src/provider/Layers/agentHistory.ts create mode 100644 apps/server/src/provider/Layers/claudeAgentHistory.test.ts create mode 100644 apps/server/src/provider/Layers/claudeAgentHistory.ts create mode 100644 apps/server/src/provider/Layers/codexAgentHistory.test.ts create mode 100644 apps/server/src/provider/Layers/codexAgentHistory.ts create mode 100644 apps/server/src/provider/Layers/grokAgentHistory.test.ts create mode 100644 apps/server/src/provider/Layers/grokAgentHistory.ts create mode 100644 apps/server/src/provider/Layers/openCodeAgentHistory.test.ts create mode 100644 apps/server/src/provider/Layers/openCodeAgentHistory.ts create mode 100644 apps/web/src/components/AgentsPanel.logic.test.ts create mode 100644 apps/web/src/components/AgentsPanel.logic.ts diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index e07681ae153b..d6596c442bf0 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -123,6 +123,7 @@ const startupDependencies = Layer.mergeAll( assertConversationRollbackSupported: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + getAgentHistory: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..2e96018702ae 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -23,6 +23,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getAgentHistory]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b88f7f012d46..464bd83e5985 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -137,6 +137,7 @@ function createProviderServiceHarness( }, }), rollbackConversation, + getAgentHistory: () => unsupported(), uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..3c2e1c759379 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -390,6 +390,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + getAgentHistory: () => unsupported(), uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..7bb768ee6381 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -141,6 +141,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + getAgentHistory: () => unsupported(), uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..059755f5850e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -7279,3 +7279,48 @@ describe("ClaudeAdapterLive", () => { ); }); }); + +for (const source of ["configured", "inherited"] as const) { + it.effect(`reads child history from the ${source} Claude home without starting a session`, () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-history-adapter-")); + const sessionId = "0945fcd9-8a8d-450a-b8cf-4ebd0ef17468"; + const directory = NodePath.join(home, "projects", "-workspace", sessionId, "subagents"); + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(directory, "agent-child.jsonl"), + JSON.stringify({ + type: "user", + uuid: "9eb84969-819b-4d5b-854f-327474810b34", + parentUuid: null, + isSidechain: true, + sessionId, + agentId: "child", + message: { role: "user", content: "Saved task" }, + }) + "\n", + ); + const harness = makeHarness({ + claudeConfig: { homePath: source === "configured" ? home : "" }, + environment: { + ...process.env, + CLAUDE_CONFIG_DIR: source === "configured" ? "/unused-claude-home" : home, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + assert.isDefined(adapter.getAgentHistory); + const result = yield* adapter.getAgentHistory!({ + threadId: ThreadId.make("stopped-history-thread"), + agentId: "child", + offset: 0, + resumeCursor: { resume: sessionId }, + }); + assert.equal(result.status, "ready"); + assert.equal(result.entries[0]?.detail, "Saved task"); + assert.isUndefined(harness.getLastCreateQueryInput()); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + }).pipe( + Effect.provide(harness.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(home, { recursive: true, force: true }))), + ); + }); +} diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81a4a197e9db..a204baf48969 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -6,6 +6,9 @@ * * @module ClaudeAdapterLive */ +import * as NodeOS from "node:os"; +import { readClaudeAgentHistory } from "./claudeAgentHistory.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { type CanUseTool, query, @@ -5028,6 +5031,38 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); + const getAgentHistory: ClaudeAdapterShape["getAgentHistory"] = Effect.fn("getAgentHistory")( + function* (input) { + const sessionId = readClaudeResumeState(input.resumeCursor)?.resume; + if (!sessionId) + return { + status: "unavailable", + entries: [], + nextOffset: null, + message: "No saved Claude session is available for this thread.", + }; + return yield* Effect.tryPromise({ + try: () => + readClaudeAgentHistory({ + sessionId, + agentId: input.agentId, + offset: input.offset, + view: input.view, + configDir: claudeEnvironment.CLAUDE_CONFIG_DIR + ? expandHomePath(claudeEnvironment.CLAUDE_CONFIG_DIR) + : path.join(NodeOS.homedir(), ".claude"), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "getSubagentMessages", + detail: "Could not read saved Claude agent history.", + cause, + }), + }); + }, + ); + const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* requireSession(threadId); @@ -5135,6 +5170,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( sendTurn, interruptTurn, readThread, + getAgentHistory, rollbackThread, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2d88e58dc1fb..28ea0c3ceb6b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1,3 +1,5 @@ +import { withCodexAppServerClient } from "./CodexProvider.ts"; +import { readCodexAgentHistory } from "./codexAgentHistory.ts"; /** * CodexAdapterLive - Scoped live implementation for the Codex provider adapter. * @@ -2569,6 +2571,51 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }); + const getAgentHistory: CodexAdapterShape["getAgentHistory"] = Effect.fn("getAgentHistory")( + function* (input) { + if (!isCodexResumeCursorSchema(input.resumeCursor)) { + return { + status: "unavailable", + entries: [], + nextOffset: null, + message: "No saved Codex session is available for this thread.", + }; + } + const parentThreadId = input.resumeCursor.threadId; + return yield* Effect.scoped( + Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient({ + binaryPath: codexConfig.binaryPath, + homePath: codexConfig.homePath, + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), + environment: options?.environment, + cwd: input.cwd ?? process.cwd(), + }); + return yield* readCodexAgentHistory({ + parentThreadId, + agentId: input.agentId, + offset: input.offset, + view: input.view, + readThread: (threadId, includeTurns) => + client.request("thread/read", { threadId, includeTurns }), + }); + }), + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/read", + detail: "Could not read saved agent history.", + cause, + }), + ), + ); + }, + ); + const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.readThread), @@ -2717,6 +2764,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( compaction: { type: "native", start: compactThread }, interruptTurn, readThread, + getAgentHistory, rollbackThread, uploadFeedback, respondToRequest, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 41fa6ed0f60a..6bb40461d36e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -212,6 +212,115 @@ it("requires a settlement to match the live Grok turn", () => { }); it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("receives underscore-prefixed Grok child lifecycle notifications", () => + Effect.gen(function* () { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-lifecycle-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })), + ); + const wrapper = writeFakeCli({ + directory: dir, + name: "lifecycle-grok", + source: ` + import { createInterface } from "node:readline"; + createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + const respond = (result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); + if (request.method === "initialize") { respond({ protocolVersion: 1, agentCapabilities: {}, authMethods: [] }); return; } + if (request.method === "session/new") { respond({ sessionId: "mock-session-1" }); return; } + if (request.method !== "session/prompt") { if (request.id !== undefined) respond({}); return; } + for (const update of [ + { sessionUpdate: "subagent_spawned", parent_session_id: "mock-session-1", child_session_id: "child", description: "Review" }, + { sessionUpdate: "subagent_finished", child_session_id: "child", status: "completed" }, + ]) process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method: "_x.ai/session/update", params: { sessionId: "mock-session-1", update } }) + "\\n"); + respond({ stopReason: "end_turn" }); + }); + `, + }); + const adapter = yield* makeTestAdapter(wrapper); + const threadId = ThreadId.make("grok-native-child-lifecycle"); + const events = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.started" || event.type === "task.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + threadId, + cwd: dir, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "Review" }); + const tasks = yield* Fiber.join(events); + assert.deepEqual( + tasks.map((event) => [event.type, event.payload.taskId]), + [ + ["task.started", "child"], + ["task.completed", "child"], + ], + ); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("reads stopped child history with an isolated initialize-only transport", () => + Effect.gen(function* () { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-history-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(dir, { recursive: true, force: true })), + ); + const log = NodePath.join(dir, "requests.ndjson"); + const wrapper = writeFakeCli({ + directory: dir, + name: "history-grok", + source: ` + import { appendFileSync } from "node:fs"; + import { createInterface } from "node:readline"; + const log = process.env.GROK_HISTORY_TEST_LOG; + process.on("SIGTERM", () => { appendFileSync(log, JSON.stringify({ method: "closed" }) + "\\n"); process.exit(0); }); + createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + appendFileSync(log, JSON.stringify({ method: request.method, params: request.params, home: process.env.GROK_HOME }) + "\\n"); + if (request.id === undefined) return; + const result = request.method === "initialize" ? { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + : request.method === "_x.ai/session/state" ? { summary: { parent_session_id: "parent", session_kind: "subagent" } } + : request.method === "_x.ai/session/updates" ? { updates: [{ method: "session/update", params: { sessionId: "child", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Saved child output" } } } }] } + : undefined; + process.stdout.write(JSON.stringify(result === undefined ? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "unexpected method" } } : { jsonrpc: "2.0", id: request.id, result }) + "\\n"); + }); + `, + }); + const adapter = yield* makeTestAdapter(wrapper, { + environment: { GROK_HISTORY_TEST_LOG: log, GROK_HOME: dir }, + }); + const threadId = ThreadId.make("stopped-grok-history"); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.isDefined(adapter.getAgentHistory); + const result = yield* adapter.getAgentHistory!({ + threadId, + agentId: "child", + offset: 0, + cwd: dir, + resumeCursor: { schemaVersion: 1, sessionId: "parent" }, + }); + assert.equal(result.status, "ready"); + assert.equal(result.entries[0]?.detail, "Saved child output"); + assert.isFalse(yield* adapter.hasSession(threadId)); + const calls = yield* Effect.promise(() => readJsonLines(log)); + assert.deepEqual( + calls.filter((call) => call.method !== "closed").map((call) => call.method), + ["initialize", "_x.ai/session/state", "_x.ai/session/updates"], + ); + assert.isTrue( + calls.filter((call) => call.method !== "closed").every((call) => call.home === dir), + ); + if (!windowsHost) assert.isTrue(calls.some((call) => call.method === "closed")); + }), + ); it.effect("sends runtime context with the current model without changing saved prompts", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-runtime-context"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 25188adcffcc..81298265e62a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -82,6 +82,12 @@ import { import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + GrokSubagentNotification, + grokSubagentTask, + readGrokAgentHistory, +} from "./grokAgentHistory.ts"; + const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("grok"); @@ -1129,6 +1135,28 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + yield* Effect.forEach( + ["x.ai/session/update", "_x.ai/session/update"], + (method) => + acp.handleExtNotification(method, GrokSubagentNotification, (notification) => + mapAcpCallbackFailure( + Effect.gen(function* () { + const ctx = sessions.get(input.threadId); + if (!ctx || ctx.stopped) return; + const task = grokSubagentTask(notification, ctx.acpSessionId); + if (!task) return; + yield* offerRuntimeEvent({ + ...task, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: resolveSessionCallbackTurnId(sessions, input.threadId), + }); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -2072,6 +2100,54 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); }); + const getAgentHistory: GrokAdapterShape["getAgentHistory"] = Effect.fn( + "GrokAdapter.getAgentHistory", + )(function* (input) { + const parentSessionId = parseGrokResume(input.resumeCursor)?.sessionId; + if (!parentSessionId || !input.cwd) + return { + status: "unavailable", + entries: [], + nextOffset: null, + message: "No saved Grok session is available for this thread.", + }; + const cwd = input.cwd; + return yield* Effect.scoped( + Effect.gen(function* () { + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }); + // Only negotiate the transport. Never create, load, or resume a session for history. + yield* acp.initialize(); + return yield* readGrokAgentHistory({ + parentSessionId, + agentId: input.agentId, + cwd, + offset: input.offset, + view: input.view, + request: acp.request, + }); + }), + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/session/updates", + detail: + "Could not read saved Grok agent history. This requires a Grok CLI with session history extensions.", + cause, + }), + ), + ); + }); + const readThread: GrokAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); @@ -2133,6 +2209,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte sendTurn, interruptTurn, readThread, + getAgentHistory, rollbackThread, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index ee5767f9d356..f85846910155 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -612,6 +612,198 @@ const questionRequest = (id: string, sessionID: string): QuestionRequest => ({ }); it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("links native Task tools to child history and keeps child text out of the parent", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const push = makeOpenCodeEventQueue(); + const threadId = asThreadId("task-history"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "task.started" || + event.type === "task.completed" || + event.type === "content.delta", + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + const part = { + id: "task-part", + messageID: "parent-message", + sessionID: "http://127.0.0.1:9999/session", + type: "tool", + callID: "task-call", + tool: "task", + state: { + status: "running", + input: { description: "Review changes" }, + title: "Review changes", + metadata: { sessionId: "ses_child" }, + time: { start: 1 }, + }, + }; + push({ type: "message.part.updated", properties: { sessionID: part.sessionID, part } }); + push({ + type: "message.part.updated", + properties: { + sessionID: "ses_child", + part: { + id: "child-text", + messageID: "child-message", + sessionID: "ses_child", + type: "text", + text: "Private child answer", + }, + }, + }); + push({ + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part: { + ...part, + state: { + ...part.state, + status: "completed", + output: "Review finished", + time: { start: 1, end: 2 }, + }, + }, + }, + }); + const collected = yield* Fiber.join(events); + NodeAssert.deepEqual( + collected.map((event) => event.type), + ["task.started", "task.completed"], + ); + NodeAssert.deepEqual( + collected.map((event) => + event.type === "task.started" + ? [event.payload.taskId, "running"] + : event.type === "task.completed" + ? [event.payload.taskId, event.payload.status] + : null, + ), + [ + ["ses_child", "running"], + ["ses_child", "completed"], + ], + ); + yield* adapter.stopSession(threadId); + }), + ); + + for (const terminal of ["session.idle", "session.error", "session.deleted"] as const) { + it.effect(`settles background agents on ${terminal}, not the launch tool return`, () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const push = makeOpenCodeEventQueue(); + const threadId = asThreadId("background-history"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.type === "task.started" || event.type === "task.completed", + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + push({ + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "launch", + messageID: "message", + sessionID: "http://127.0.0.1:9999/session", + type: "tool", + tool: "task", + callID: "launch", + state: { + status: "completed", + input: { description: "Watch changes" }, + title: "Watch changes", + output: "Background launched", + metadata: { sessionId: "ses_background", background: true }, + time: { start: 1, end: 2 }, + }, + }, + }, + }); + const nativeTerminal = { + type: terminal, + properties: { + sessionID: "ses_background", + info: { id: "ses_background" }, + error: { name: "UnknownError", data: { message: "Failed" } }, + }, + }; + push(nativeTerminal); + const collected = yield* Fiber.join(events); + NodeAssert.equal(collected[0]?.type, "task.started"); + NodeAssert.equal(collected[1]?.type, "task.completed"); + NodeAssert.deepEqual(collected[1]?.raw?.payload, nativeTerminal); + const completion = collected[1]; + NodeAssert.equal( + completion?.type === "task.completed" ? completion.payload.status : null, + terminal === "session.error" + ? "failed" + : terminal === "session.deleted" + ? "stopped" + : "completed", + ); + yield* adapter.stopSession(threadId); + }), + ); + } + + it.effect("reads saved child history without starting or mutating a session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + runtimeMock.state.sessionParentById.set("ses_child", "ses_parent"); + runtimeMock.state.messages = [ + { + info: { id: "message", role: "assistant" }, + parts: [ + { + id: "part", + type: "text", + sessionID: "ses_child", + messageID: "message", + text: "Saved answer", + }, + ], + }, + ]; + const result = yield* adapter.getAgentHistory!({ + threadId: asThreadId("stopped"), + agentId: "ses_child", + offset: 0, + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + cwd: "/saved/workspace", + }); + NodeAssert.equal(result.status, "ready"); + NodeAssert.equal(result.entries[0]?.detail, "Saved answer"); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_child"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateInputs, []); + NodeAssert.deepEqual(runtimeMock.state.sessionUpdateCalls, []); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, []); + NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + }), + ); + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 742ee9b86d6a..cbfea34f0930 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -7,6 +7,7 @@ import { type ProviderSendTurnInput, type ProviderSession, RuntimeItemId, + RuntimeTaskId, RuntimeRequestId, ThreadId, type ToolLifecycleItemType, @@ -60,6 +61,8 @@ import { } from "../opencodeRuntime.ts"; import * as Option from "effect/Option"; +import { readOpenCodeAgentHistory } from "./openCodeAgentHistory.ts"; + const PROVIDER = ProviderDriverKind.make("opencode"); /** @@ -340,6 +343,15 @@ interface OpenCodeSessionContext { readonly directory: string; readonly openCodeSessionId: string; readonly relatedSessionIds: Set; + readonly agentTasks: Map< + string, + { + toolUseId: string; + description: string; + turnId: TurnId | undefined; + status: "running" | "completed" | "failed" | "stopped"; + } + >; readonly resolvedRequestIds: Set; readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; @@ -2210,6 +2222,45 @@ export function makeOpenCodeAdapter( const payloadSessionId = openCodeEventSessionId(event); const isParentEvent = payloadSessionId === context.openCodeSessionId; + const childTask = payloadSessionId ? context.agentTasks.get(payloadSessionId) : undefined; + if ( + !isParentEvent && + payloadSessionId && + childTask && + (event.type === "session.idle" || + event.type === "session.error" || + event.type === "session.deleted" || + (event.type === "session.status" && event.properties.status.type === "idle")) + ) { + const status = + event.type === "session.error" + ? "failed" + : event.type === "session.deleted" + ? "stopped" + : "completed"; + if (childTask.status === "running") { + childTask.status = status; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: childTask.turnId, + raw: event, + })), + type: "task.completed", + payload: { + taskId: RuntimeTaskId.make(payloadSessionId), + status, + taskType: "subagent", + agentKind: "agent", + timelineBypass: true, + toolUseId: childTask.toolUseId, + title: childTask.description, + }, + }); + } + return; + } + let isKnownPendingTerminalEvent = false; if ( payloadSessionId !== undefined && @@ -2505,6 +2556,67 @@ export function makeOpenCodeAdapter( payload, }; yield* emit(runtimeEvent); + // Native Task metadata carries the child session ID used by the history API. + const metadata = part.state.status === "pending" ? undefined : part.state.metadata; + const childSessionId = metadata?.sessionId; + if ( + part.tool === "task" && + typeof childSessionId === "string" && + childSessionId.trim() + ) { + const previous = context.agentTasks.get(childSessionId); + const description = + typeof part.state.input.description === "string" && + part.state.input.description.trim() + ? part.state.input.description + : "Subagent"; + const status = + part.state.status === "error" + ? "failed" + : part.state.status === "completed" && metadata?.background !== true + ? "completed" + : "running"; + // A background launch returning is not child completion. Its native idle/error event settles it. + if ( + previous?.status !== status && + !( + previous && + previous.toolUseId === part.callID && + previous.status !== "running" && + status === "running" + ) + ) { + context.agentTasks.set(childSessionId, { + toolUseId: part.callID, + description, + turnId, + status, + }); + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }); + const linkage = { + taskId: RuntimeTaskId.make(childSessionId), + taskType: "subagent", + agentKind: "agent" as const, + timelineBypass: true, + toolUseId: part.callID, + title: description, + }; + if (status === "running") + yield* emit({ + ...base, + type: "task.started", + payload: { ...linkage, description }, + }); + else + yield* emit({ ...base, type: "task.completed", payload: { ...linkage, status } }); + } + } } break; } @@ -2983,6 +3095,7 @@ export function makeOpenCodeAdapter( directory, openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), + agentTasks: new Map(), resolvedRequestIds: new Set(), autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), @@ -3772,6 +3885,68 @@ export function makeOpenCodeAdapter( const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => Effect.sync(() => sessions.has(threadId)); + const getAgentHistory: OpenCodeAdapterShape["getAgentHistory"] = Effect.fn("getAgentHistory")( + function* (input) { + const parentSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; + if (!parentSessionId) + return { + status: "unavailable", + entries: [], + nextOffset: null, + message: "No saved OpenCode session is available for this thread.", + }; + const read = (client: OpencodeClient) => + readOpenCodeAgentHistory({ + parentSessionId, + agentId: input.agentId, + offset: input.offset, + view: input.view, + readSession: (sessionID) => + runOpenCodeSdk("session.get", (signal) => + client.session.get({ sessionID }, { signal }), + ).pipe(Effect.map((response) => response.data)), + readMessages: (sessionID) => + runOpenCodeSdk("session.messages", (signal) => + client.session.messages({ sessionID }, { signal }), + ).pipe(Effect.map((response) => response.data ?? [])), + }); + const context = sessions.get(input.threadId); + return yield* Effect.scoped( + Effect.gen(function* () { + if (context?.openCodeSessionId === parentSessionId) return yield* read(context.client); + const directory = input.cwd ?? serverConfig.cwd; + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + directory, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + ...(options?.environment ? { environment: options.environment } : {}), + }); + return yield* read( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), + }), + ); + }), + ).pipe( + Effect.timeout("20 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: "Could not read saved agent history.", + cause, + }), + ), + ); + }, + ); + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* ensureSessionContext(sessions, threadId); @@ -3853,6 +4028,7 @@ export function makeOpenCodeAdapter( listSessions, hasSession, readThread, + getAgentHistory, rollbackThread, stopAll, get streamEvents() { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..bba9c3305d8a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -257,6 +257,14 @@ function makeFakeCodexAdapter( Effect.succeed({ threadId, turns: [] }), ); + const getAgentHistory = vi.fn( + ( + _input: Parameters< + NonNullable["getAgentHistory"]> + >[0], + ) => Effect.succeed({ status: "ready" as const, entries: [], nextOffset: null, message: null }), + ); + const uploadFeedback = vi.fn( ( input: ProviderUploadFeedbackInput, @@ -295,6 +303,7 @@ function makeFakeCodexAdapter( readThread, rollbackThread, ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), + ...(provider === CODEX_DRIVER || provider === CLAUDE_AGENT_DRIVER ? { getAgentHistory } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -332,6 +341,7 @@ function makeFakeCodexAdapter( readThread, rollbackThread, uploadFeedback, + getAgentHistory, stopAll, }; } @@ -1974,6 +1984,63 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("reads saved agent history without recovering a stopped session", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-agent-history-stopped"); + const cwd = fixtureCwd("agent-history"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd, + resumeCursor: { threadId: "native-parent" }, + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + routing.codex.getAgentHistory.mockClear(); + const result = yield* provider.getAgentHistory({ + threadId, + agentId: "native-child", + offset: 50, + }); + assert.equal(result.status, "ready"); + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.equal(routing.codex.sendTurn.mock.calls.length, 0); + assert.deepStrictEqual(routing.codex.getAgentHistory.mock.calls, [ + [ + { + threadId, + agentId: "native-child", + offset: 50, + cwd, + resumeCursor: { threadId: "native-parent" }, + }, + ], + ]); + }), + ); + + it.effect("reports unsupported history without restarting the provider", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-agent-history-unsupported"); + yield* provider.startSession(threadId, { + provider: CURSOR_DRIVER, + providerInstanceId: ProviderInstanceId.make("cursor"), + threadId, + runtimeMode: "full-access", + }); + yield* routing.cursor.stopSession(threadId); + routing.cursor.startSession.mockClear(); + const result = yield* provider.getAgentHistory({ threadId, agentId: "child", offset: 0 }); + assert.equal(result.status, "unsupported"); + assert.equal(routing.cursor.startSession.mock.calls.length, 0); + }), + ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 016bcd5c4a28..f2bffeef53b1 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -2142,6 +2142,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const getAgentHistory: ProviderServiceMethod<"getAgentHistory"> = Effect.fn("getAgentHistory")( + function* (input) { + const binding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); + if (!binding) { + return yield* toValidationError( + "ProviderService.getAgentHistory", + "No saved provider session exists for this thread.", + ); + } + const instanceId = yield* requireBindingInstanceId( + "ProviderService.getAgentHistory", + binding, + ); + const adapter = yield* registry.getByInstance(instanceId); + if (!adapter.getAgentHistory) { + return { + status: "unsupported", + entries: [], + nextOffset: null, + message: "Agent history is not supported by this provider yet.", + }; + } + return yield* adapter.getAgentHistory({ + ...input, + resumeCursor: binding.resumeCursor, + cwd: readPersistedCwd(binding.runtimePayload), + }); + }, + ); + const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -2276,6 +2306,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( assertConversationRollbackSupported, rollbackConversation, uploadFeedback, + getAgentHistory, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3ab2b4618a9e..fdf7730188cb 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -213,6 +213,7 @@ describe("ProviderSessionReaper", () => { }); }, rollbackConversation: () => unsupported(), + getAgentHistory: () => unsupported(), uploadFeedback: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/agentHistory.test.ts b/apps/server/src/provider/Layers/agentHistory.test.ts new file mode 100644 index 000000000000..b4e575fa493a --- /dev/null +++ b/apps/server/src/provider/Layers/agentHistory.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "@effect/vitest"; +import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; + +describe("agent history selection", () => { + const entries = Array.from({ length: 130 }, (_, index) => + agentHistoryEntry( + String(index), + index % 2 ? "assistant" : "tool", + `Entry ${index}`, + "x".repeat(1000), + ), + ); + it("returns the newest five tools beyond the first page with compact details", () => { + const page = collectAgentHistory({ offset: 0, view: "recent-tools" }); + for (const entry of entries) page.add(entry); + expect(page.result().entries.map((entry) => entry.id)).toEqual([ + "120", + "122", + "124", + "126", + "128", + ]); + expect( + page.result().entries.every((entry) => entry.detail.length === 240 && entry.truncated), + ).toBe(true); + expect(page.result().nextOffset).toBeNull(); + }); + it("opens on the latest page and supplies its position for previous navigation", () => { + const page = collectAgentHistory({ offset: 0, view: "latest" }); + for (const entry of entries) page.add(entry); + expect(page.result().startOffset).toBe(80); + expect(page.result().entries.map((entry) => entry.id)).toEqual( + entries.slice(-50).map((entry) => entry.id), + ); + expect(page.result().nextOffset).toBeNull(); + }); + it("keeps forward pagination unchanged", () => { + const page = collectAgentHistory({ offset: 50 }); + for (const entry of entries) if (page.add(entry)) break; + expect(page.result().entries.map((entry) => entry.id)).toEqual( + entries.slice(50, 100).map((entry) => entry.id), + ); + expect(page.result().nextOffset).toBe(100); + }); +}); diff --git a/apps/server/src/provider/Layers/agentHistory.ts b/apps/server/src/provider/Layers/agentHistory.ts new file mode 100644 index 000000000000..64df430447a2 --- /dev/null +++ b/apps/server/src/provider/Layers/agentHistory.ts @@ -0,0 +1,66 @@ +import type { + AgentHistoryEntry, + OrchestrationGetAgentHistoryInput, + OrchestrationGetAgentHistoryResult, +} from "@t3tools/contracts"; + +/** Keep history payloads bounded; omitted detail is explicitly marked in the UI. */ +export function agentHistoryEntry( + id: string, + kind: AgentHistoryEntry["kind"], + title: string, + detail = "", +): AgentHistoryEntry { + return { + id, + kind, + title: title.slice(0, 500), + detail: detail.slice(0, 8000), + truncated: title.length > 500 || detail.length > 8000, + }; +} + +/** Full history pages forward; previews retain only the latest five tools, oldest first. */ +export function collectAgentHistory( + input: Pick, +) { + const entries: AgentHistoryEntry[] = []; + let index = 0; + let nextOffset: number | null = null; + return { + add(entry: AgentHistoryEntry): boolean { + if (input.view === "latest") { + index++; + entries.push(entry); + if (entries.length > 50) entries.shift(); + return false; + } + if (input.view === "recent-tools") { + if (entry.kind !== "tool") return false; + entries.push({ + ...entry, + detail: entry.detail.slice(0, 240), + truncated: entry.truncated || entry.detail.length > 240, + }); + if (entries.length > 5) entries.shift(); + return false; + } + if (index++ < input.offset) return false; + if (entries.length === 50) { + nextOffset = input.offset + entries.length; + return true; + } + entries.push(entry); + return false; + }, + result(): OrchestrationGetAgentHistoryResult { + return { + status: "ready", + entries, + nextOffset, + message: null, + ...(input.view === "latest" ? { startOffset: Math.max(0, index - entries.length) } : {}), + }; + }, + }; +} diff --git a/apps/server/src/provider/Layers/claudeAgentHistory.test.ts b/apps/server/src/provider/Layers/claudeAgentHistory.test.ts new file mode 100644 index 000000000000..34d3da438f47 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeAgentHistory.test.ts @@ -0,0 +1,142 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeCrypto from "node:crypto"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import { readClaudeAgentHistory } from "./claudeAgentHistory.ts"; + +const sessionId = "0945fcd9-8a8d-450a-b8cf-4ebd0ef17468"; +let configDir: string; +beforeEach(async () => { + configDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "claude-agent-history-")); +}); +afterEach(async () => { + await NodeFSP.rm(configDir, { recursive: true, force: true }); +}); + +async function save(agentId: string, contents: ReadonlyArray, nested = false) { + const directory = NodePath.join( + configDir, + "projects", + "-workspace", + sessionId, + "subagents", + ...(nested ? ["workflow", "review"] : []), + ); + await NodeFSP.mkdir(directory, { recursive: true }); + let parentUuid: string | null = null; + const lines = contents.map((content, index) => { + const uuid = NodeCrypto.randomUUID(); + const type = + index === 0 || + (Array.isArray(content) && content.some((block) => block.type === "tool_result")) + ? "user" + : "assistant"; + const row = { + type, + uuid, + parentUuid, + isSidechain: true, + sessionId, + agentId, + timestamp: DateTime.formatIso(DateTime.makeUnsafe(index * 1000)), + message: { role: type, content }, + }; + parentUuid = uuid; + return JSON.stringify(row); + }); + const file = NodePath.join(directory, `agent-${agentId}.jsonl`); + await NodeFSP.writeFile(file, lines.join("\n") + "\n"); + return file; +} + +const read = (agentId = "child", offset = 0) => + readClaudeAgentHistory({ configDir, sessionId, agentId, offset }); + +describe("Claude saved agent history", () => { + it("reads nested transcripts, preserves calls and results, and bounds entry detail", async () => { + await save( + "child", + [ + "Review the changes", + [ + { type: "text", text: "Checking the file" }, + { type: "tool_use", id: "tool1", name: "Read", input: { file_path: "index.ts" } }, + ], + [ + { + type: "tool_result", + tool_use_id: "tool1", + content: [{ type: "text", text: "x".repeat(9000) }], + }, + ], + ], + true, + ); + const result = await read(); + expect(result.status).toBe("ready"); + expect(result.entries.map((entry) => entry.title)).toEqual([ + "Prompt", + "Agent", + "Read", + "Tool result", + ]); + expect(result.entries[2]?.detail).toContain("index.ts"); + expect(result.entries[3]?.detail).toHaveLength(8000); + expect(result.entries[3]?.truncated).toBe(true); + }); + + it("paginates by visible entries without losing or repeating blocks", async () => { + await save("child", [ + "Prompt", + Array.from({ length: 55 }, (_, index) => ({ type: "text", text: `entry ${index}` })), + ]); + const first = await read(); + const second = await read("child", first.nextOffset!); + expect(first.entries).toHaveLength(50); + expect(second.entries).toHaveLength(6); + expect(second.nextOffset).toBeNull(); + expect(new Set([...first.entries, ...second.entries].map((entry) => entry.id)).size).toBe(56); + expect(second.entries.at(-1)?.detail).toBe("entry 54"); + }); + + it("does not cross parent sessions, provider homes, or follow child symlinks", async () => { + const file = await save("child", ["private"]); + expect( + ( + await readClaudeAgentHistory({ + configDir, + sessionId: NodeCrypto.randomUUID(), + agentId: "child", + offset: 0, + }) + ).status, + ).toBe("unavailable"); + expect( + ( + await readClaudeAgentHistory({ + configDir: NodePath.join(configDir, "other-home"), + sessionId, + agentId: "child", + offset: 0, + }) + ).status, + ).toBe("unavailable"); + expect((await read("../../child")).status).toBe("unavailable"); + if (HostProcessPlatform.defaultValue() !== "win32") { + await NodeFSP.symlink(file, NodePath.join(NodePath.dirname(file), "agent-alias.jsonl")); + expect((await read("alias")).status).toBe("unavailable"); + } + }); + + it("tolerates a record still being written but reports corruption in completed records", async () => { + const file = await save("child", ["Prompt", [{ type: "text", text: "Done" }]]); + await NodeFSP.appendFile(file, '{"type":'); + expect((await read()).entries).toHaveLength(2); + await NodeFSP.appendFile(file, "\n"); + await expect(read()).rejects.toThrow(); + }); +}); diff --git a/apps/server/src/provider/Layers/claudeAgentHistory.ts b/apps/server/src/provider/Layers/claudeAgentHistory.ts new file mode 100644 index 000000000000..06159f3ba165 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeAgentHistory.ts @@ -0,0 +1,222 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { + getSubagentMessages, + type SessionMessage, + type SessionStoreEntry, +} from "@anthropic-ai/claude-agent-sdk"; +import type { AgentHistoryEntry, OrchestrationGetAgentHistoryResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; + +const isAgentId = Schema.is(Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/))); +const isSessionId = Schema.is(Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9-]+$/))); + +const decodeTranscriptEntry = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ type: Schema.String })), + { onExcessProperty: "preserve" }, +); +const ContentBlock = Schema.Struct({ + type: Schema.String, + text: Schema.optionalKey(Schema.String), + thinking: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + input: Schema.optionalKey(Schema.Unknown), + content: Schema.optionalKey(Schema.Unknown), + is_error: Schema.optionalKey(Schema.Boolean), +}); +const decodeMessage = Schema.decodeUnknownSync( + Schema.Struct({ + content: Schema.Union([Schema.String, Schema.Array(ContentBlock)]), + }), +); +const decodeTextContent = Schema.decodeUnknownOption( + Schema.Array( + Schema.Struct({ + type: Schema.String, + text: Schema.optionalKey(Schema.String), + }), + ), +); + +const unavailable = (message: string): OrchestrationGetAgentHistoryResult => ({ + status: "unavailable", + entries: [], + nextOffset: null, + message, +}); + +async function directoryEntries(path: string) { + try { + return await NodeFSP.readdir(path, { withFileTypes: true }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +async function isDirectory(path: string) { + try { + return (await NodeFSP.lstat(path)).isDirectory(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +/** Find only regular child transcripts inside the persisted parent's directory. */ +async function findTranscript( + directory: string, + filename: string, + depth = 0, +): Promise { + if (depth > 16) return null; + const entries = await directoryEntries(directory); + if (entries.some((entry) => entry.name === filename && entry.isFile())) + return NodePath.join(directory, filename); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const found = await findTranscript(NodePath.join(directory, entry.name), filename, depth + 1); + if (found) return found; + } + return null; +} + +function resultText(content: unknown): string { + if (typeof content === "string") return content; + const blocks = decodeTextContent(content); + return blocks._tag === "Some" + ? blocks.value.flatMap((block) => (block.text ? [block.text] : [])).join("\n") + : ""; +} + +export function claudeHistoryEntries(message: SessionMessage): AgentHistoryEntry[] { + const { content } = decodeMessage(message.message); + if (typeof content === "string") + return content + ? [ + agentHistoryEntry( + message.uuid, + message.type === "user" ? "user" : "assistant", + message.type === "user" ? "Prompt" : "Agent", + content, + ), + ] + : []; + return content.flatMap((block, index): AgentHistoryEntry[] => { + const id = `${message.uuid}:${index}`; + switch (block.type) { + case "text": + return block.text + ? [ + agentHistoryEntry( + id, + message.type === "user" ? "user" : "assistant", + message.type === "user" ? "Prompt" : "Agent", + block.text, + ), + ] + : []; + case "thinking": + return block.thinking + ? [agentHistoryEntry(id, "reasoning", "Reasoning", block.thinking)] + : []; + case "tool_use": + return [ + agentHistoryEntry( + id, + "tool", + block.name ?? "Tool", + JSON.stringify(block.input ?? {}, null, 2), + ), + ]; + case "tool_result": + return [ + agentHistoryEntry( + id, + "tool", + block.is_error ? "Tool error" : "Tool result", + resultText(block.content), + ), + ]; + default: + return []; + } + }); +} + +/** The SDK rebuilds the conversation chain. A read-only store keeps its reads instance-local. */ +export async function readClaudeAgentHistory(input: { + configDir: string; + sessionId: string; + agentId: string; + offset: number; + view?: "recent-tools" | "latest" | undefined; +}): Promise { + if (!isAgentId(input.agentId) || !isSessionId(input.sessionId)) { + return unavailable("No saved transcript is available for this agent."); + } + const projectsDir = NodePath.join(input.configDir, "projects"); + let transcript: string | null = null; + for (const project of await directoryEntries(projectsDir)) { + if (!project.isDirectory()) continue; + const projectDir = NodePath.join(projectsDir, project.name); + const sessionDir = NodePath.join(projectDir, input.sessionId); + if ( + !(await isDirectory(sessionDir)) || + !(await isDirectory(NodePath.join(sessionDir, "subagents"))) + ) + continue; + transcript = await findTranscript( + NodePath.join(sessionDir, "subagents"), + `agent-${input.agentId}.jsonl`, + ); + if (transcript) break; + } + if (!transcript) + return unavailable("Claude has no saved transcript for this agent in this session."); + const handle = await NodeFSP.open( + transcript, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW, + ); + let contents: string; + try { + const stat = await handle.stat(); + if (!stat.isFile()) return unavailable("The agent transcript is not a regular file."); + if (stat.size > 64 * 1024 * 1024) + return unavailable("This agent transcript is too large to load."); + contents = await handle.readFile("utf8"); + } finally { + await handle.close(); + } + const lines = contents.split("\n"); + const entries: SessionStoreEntry[] = []; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]!; + if (!line.trim()) continue; + try { + entries.push(decodeTranscriptEntry(line)); + } catch (error) { + // A running Claude process may still be writing the last JSONL record. + if (index === lines.length - 1 && !contents.endsWith("\n")) break; + throw error; + } + } + const messages = await getSubagentMessages(input.sessionId, input.agentId, { + sessionStore: { + append: async () => { + throw new Error("Agent history is read-only."); + }, + load: async () => entries, + }, + }); + const page = collectAgentHistory(input); + for (const message of messages) { + for (const entry of claudeHistoryEntries(message)) { + if (page.add(entry)) return page.result(); + } + } + return page.result(); +} diff --git a/apps/server/src/provider/Layers/codexAgentHistory.test.ts b/apps/server/src/provider/Layers/codexAgentHistory.test.ts new file mode 100644 index 000000000000..10bc04f1ad3d --- /dev/null +++ b/apps/server/src/provider/Layers/codexAgentHistory.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type { V2ThreadReadResponse } from "effect-codex-app-server/schema"; +import { OrchestrationGetAgentHistoryResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { readCodexAgentHistory } from "./codexAgentHistory.ts"; + +const isAgentHistoryResult = Schema.is(OrchestrationGetAgentHistoryResult); + +function thread(id: string, parent: string | null, count = 1): V2ThreadReadResponse { + return { + thread: { + id, + cliVersion: "test", + createdAt: 0, + updatedAt: 0, + cwd: "/workspace", + ephemeral: false, + modelProvider: "openai", + preview: "", + sessionId: "session", + status: { type: "idle" }, + source: parent + ? { subAgent: { thread_spawn: { parent_thread_id: parent, depth: 1 } } } + : "appServer", + turns: [ + { + id: "turn", + status: "completed", + error: null, + items: Array.from({ length: count }, (_, index) => ({ + id: `item-${index}`, + type: "commandExecution" as const, + command: `read file ${index}`, + cwd: "/workspace", + commandActions: [], + status: "completed" as const, + exitCode: 0, + aggregatedOutput: "x".repeat(9000), + })), + }, + ], + }, + }; +} + +describe("saved Codex agent history", () => { + it.effect("reads a stopped nested child's history with bounded, nonoverlapping pages", () => + Effect.gen(function* () { + const calls: Array<[string, boolean]> = []; + const readThread = (id: string, includeTurns: boolean) => { + calls.push([id, includeTurns]); + return Effect.succeed(thread(id, id === "child" ? "coordinator" : "parent", 53)); + }; + const first = yield* readCodexAgentHistory({ + parentThreadId: "parent", + agentId: "child", + offset: 0, + readThread, + }); + expect(calls).toEqual([ + ["child", false], + ["coordinator", false], + ["child", true], + ]); + expect(first.entries).toHaveLength(50); + expect(first.nextOffset).toBe(50); + expect(first.entries[0]?.truncated).toBe(true); + expect(first.entries[0]?.detail).toHaveLength(8000); + expect(isAgentHistoryResult(first)).toBe(true); + const next = yield* readCodexAgentHistory({ + parentThreadId: "parent", + agentId: "child", + offset: first.nextOffset!, + readThread, + }); + expect(next.entries.map((entry) => entry.id)).toEqual([ + "turn:item-50", + "turn:item-51", + "turn:item-52", + ]); + expect(next.nextOffset).toBeNull(); + }), + ); + + for (const scenario of ["unrelated", "cycle", "parent"] as const) { + it.effect(`rejects ${scenario} without loading conversation content`, () => + Effect.gen(function* () { + const calls: boolean[] = []; + const result = yield* readCodexAgentHistory({ + parentThreadId: "parent", + agentId: scenario === "parent" ? "parent" : "child", + offset: 0, + readThread: (id, includeTurns) => { + calls.push(includeTurns); + return Effect.succeed(thread(id, scenario === "cycle" ? "child" : null)); + }, + }); + expect(result.status).toBe("unavailable"); + expect(result.entries).toEqual([]); + expect(calls).not.toContain(true); + }), + ); + } + + it.effect("surfaces provider read failures instead of claiming there is no activity", () => + Effect.gen(function* () { + const error = new Error("history missing"); + const result = yield* readCodexAgentHistory({ + parentThreadId: "parent", + agentId: "child", + offset: 0, + readThread: () => Effect.fail(error), + }).pipe(Effect.flip); + expect(result).toBe(error); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/codexAgentHistory.ts b/apps/server/src/provider/Layers/codexAgentHistory.ts new file mode 100644 index 000000000000..3e413e26d217 --- /dev/null +++ b/apps/server/src/provider/Layers/codexAgentHistory.ts @@ -0,0 +1,134 @@ +import type { AgentHistoryEntry, OrchestrationGetAgentHistoryResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type { + V2ThreadReadResponse, + V2ThreadReadResponse__ThreadItem, +} from "effect-codex-app-server/schema"; + +import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; + +export function codexHistoryEntry( + item: V2ThreadReadResponse__ThreadItem, +): AgentHistoryEntry | null { + switch (item.type) { + case "userMessage": + return agentHistoryEntry( + item.id, + "user", + "Prompt", + item.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + ); + case "agentMessage": + return agentHistoryEntry(item.id, "assistant", "Agent", item.text); + case "reasoning": + return agentHistoryEntry( + item.id, + "reasoning", + "Reasoning summary", + (item.summary ?? []).join("\n"), + ); + case "plan": + return agentHistoryEntry(item.id, "assistant", "Plan", item.text); + case "commandExecution": + return agentHistoryEntry( + item.id, + "tool", + item.command, + [ + item.aggregatedOutput, + item.exitCode === null || item.exitCode === undefined + ? null + : `Exit code: ${item.exitCode}`, + ] + .filter((value) => value !== null && value !== undefined) + .join("\n"), + ); + case "fileChange": + return agentHistoryEntry( + item.id, + "tool", + "File changes", + item.changes.map((change) => `${change.path}\n${change.diff}`).join("\n\n"), + ); + case "mcpToolCall": + return agentHistoryEntry( + item.id, + "tool", + `${item.server}: ${item.tool}`, + JSON.stringify( + { arguments: item.arguments, result: item.result, error: item.error }, + null, + 2, + ), + ); + case "dynamicToolCall": + return agentHistoryEntry( + item.id, + "tool", + item.tool, + JSON.stringify({ arguments: item.arguments, result: item.contentItems }, null, 2), + ); + case "webSearch": + return agentHistoryEntry(item.id, "tool", `Search: ${item.query}`); + case "collabAgentToolCall": + return agentHistoryEntry(item.id, "tool", item.tool, item.prompt ?? item.status); + case "imageView": + return agentHistoryEntry(item.id, "tool", `View image: ${item.path}`); + case "imageGeneration": + return agentHistoryEntry(item.id, "tool", "Generate image", item.savedPath ?? item.status); + case "enteredReviewMode": + case "exitedReviewMode": + return agentHistoryEntry(item.id, "assistant", "Review", item.review); + default: + return null; + } +} + +const unavailable = (message: string): OrchestrationGetAgentHistoryResult => ({ + status: "unavailable", + entries: [], + nextOffset: null, + message, +}); + +/** Verify native ancestry before reading content, including nested children. No resume/start calls. */ +export const readCodexAgentHistory = Effect.fn("readCodexAgentHistory")(function* (input: { + parentThreadId: string; + agentId: string; + offset: number; + view?: "recent-tools" | "latest" | undefined; + readThread: (threadId: string, includeTurns: boolean) => Effect.Effect; +}) { + const seen = new Set([input.parentThreadId]); + let currentId = input.agentId; + let belongsToParent = false; + for (let depth = 0; depth < 32; depth++) { + if (seen.has(currentId)) break; + seen.add(currentId); + const { thread } = yield* input.readThread(currentId, false); + const source = thread.source; + if ( + typeof source !== "object" || + !("subAgent" in source) || + typeof source.subAgent !== "object" || + !("thread_spawn" in source.subAgent) + ) + break; + currentId = source.subAgent.thread_spawn.parent_thread_id; + if (currentId === input.parentThreadId) { + belongsToParent = true; + break; + } + } + if (!belongsToParent) + return unavailable("This agent does not belong to the saved provider session."); + const { thread } = yield* input.readThread(input.agentId, true); + const page = collectAgentHistory(input); + for (const turn of thread.turns) { + for (const item of turn.items) { + const entry = codexHistoryEntry(item); + if (entry && page.add({ ...entry, id: `${turn.id}:${entry.id}` })) return page.result(); + } + } + return page.result(); +}); diff --git a/apps/server/src/provider/Layers/grokAgentHistory.test.ts b/apps/server/src/provider/Layers/grokAgentHistory.test.ts new file mode 100644 index 000000000000..4bcc7f9e00a0 --- /dev/null +++ b/apps/server/src/provider/Layers/grokAgentHistory.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { grokHistoryEntries, grokSubagentTask, readGrokAgentHistory } from "./grokAgentHistory.ts"; + +const envelope = (update: Record, sessionId = "child") => ({ + method: "session/update", + params: { sessionId, update }, +}); +describe("Grok saved child history", () => { + it("folds chunks and tool results, excluding unrelated sessions", () => { + const entries = grokHistoryEntries( + [ + envelope({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello " }, + }), + envelope({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "world" }, + }), + envelope({ + sessionUpdate: "tool_call", + toolCallId: "tool", + title: "Read file", + rawInput: { path: "a.ts" }, + }), + envelope({ + sessionUpdate: "tool_call_update", + toolCallId: "tool", + content: [{ type: "content", content: { type: "text", text: "file contents" } }], + }), + envelope( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "parent secret" }, + }, + "parent", + ), + ], + "child", + ); + expect(entries.map((entry) => [entry.kind, entry.detail])).toEqual([ + ["assistant", "Hello world"], + ["tool", "file contents"], + ]); + }); + it("preserves truncation through status-only updates", () => { + const entries = grokHistoryEntries( + [ + envelope({ + sessionUpdate: "tool_call", + toolCallId: "tool", + content: [{ content: { text: "x".repeat(9000) } }], + }), + envelope({ sessionUpdate: "tool_call_update", toolCallId: "tool", status: "completed" }), + ], + "child", + ); + expect(entries[0]?.truncated).toBe(true); + expect(entries[0]?.detail).toHaveLength(8000); + }); + it.effect("verifies ancestry before reading and pages visible rows", () => + Effect.gen(function* () { + const calls: string[] = []; + const updates = Array.from({ length: 52 }, (_, index) => + envelope({ sessionUpdate: "tool_call", toolCallId: String(index), title: `Tool ${index}` }), + ); + const request = (method: string) => { + calls.push(method); + return Effect.succeed( + method.endsWith("state") + ? { summary: { parent_session_id: "parent", session_kind: "subagent" } } + : { updates }, + ); + }; + const first = yield* readGrokAgentHistory({ + parentSessionId: "parent", + agentId: "child", + cwd: "/workspace", + offset: 0, + request, + }); + expect(first.entries).toHaveLength(50); + expect(first.nextOffset).toBe(50); + const last = yield* readGrokAgentHistory({ + parentSessionId: "parent", + agentId: "child", + cwd: "/workspace", + offset: 50, + request, + }); + expect(last.entries.map((entry) => entry.title)).toEqual(["Tool 50", "Tool 51"]); + expect(last.nextOffset).toBeNull(); + expect(calls).toEqual([ + "_x.ai/session/state", + "_x.ai/session/updates", + "_x.ai/session/state", + "_x.ai/session/updates", + ]); + }), + ); + it.effect("does not read unrelated or cyclic child transcripts", () => + Effect.gen(function* () { + const calls: string[] = []; + const result = yield* readGrokAgentHistory({ + parentSessionId: "parent", + agentId: "child", + cwd: "/workspace", + offset: 0, + request: (method) => { + calls.push(method); + return Effect.succeed({ + summary: { parent_session_id: "child", session_kind: "subagent" }, + }); + }, + }); + expect(result.status).toBe("unavailable"); + expect(calls).toEqual(["_x.ai/session/state"]); + }), + ); + it("maps native child lifecycle to stable IDs without leaking other sessions", () => { + const spawn = { + sessionId: "parent", + update: { + sessionUpdate: "subagent_spawned", + child_session_id: "child", + parent_session_id: "parent", + description: "Review", + }, + }; + expect(grokSubagentTask(spawn, "parent")).toMatchObject({ + type: "task.started", + payload: { taskId: "child", agentId: "child", description: "Review" }, + }); + expect(grokSubagentTask(spawn, "unrelated")).toBeUndefined(); + expect( + grokSubagentTask( + { + sessionId: "parent", + update: { + sessionUpdate: "subagent_finished", + child_session_id: "child", + status: "cancelled", + }, + }, + "parent", + ), + ).toMatchObject({ type: "task.completed", payload: { taskId: "child", status: "stopped" } }); + }); +}); diff --git a/apps/server/src/provider/Layers/grokAgentHistory.ts b/apps/server/src/provider/Layers/grokAgentHistory.ts new file mode 100644 index 000000000000..436fab0d1aaf --- /dev/null +++ b/apps/server/src/provider/Layers/grokAgentHistory.ts @@ -0,0 +1,208 @@ +import type { + AgentHistoryEntry, + OrchestrationGetAgentHistoryResult, + TaskStartedPayload, + TaskCompletedPayload, +} from "@t3tools/contracts"; +import { RuntimeTaskId } from "@t3tools/contracts"; +import { Effect, Schema } from "effect"; +import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; + +const RecordValue = Schema.Record(Schema.String, Schema.Unknown); +const decodeRecord = Schema.decodeUnknownOption(RecordValue); +function record(value: unknown) { + const decoded = decodeRecord(value); + return decoded._tag === "Some" ? decoded.value : {}; +} +function text(value: unknown) { + return typeof value === "string" ? value : ""; +} +const State = Schema.Struct({ + summary: Schema.Struct({ + parent_session_id: Schema.optional(Schema.String), + session_kind: Schema.optional(Schema.String), + }), +}); +const Updates = Schema.Struct({ + updates: Schema.Array(Schema.Struct({ method: Schema.String, params: RecordValue })), +}); +export const GrokSubagentNotification = Schema.Struct({ + sessionId: Schema.String, + update: RecordValue, +}); + +/** Grok's durable child id is also its subagent id; retain it for history reads. */ +export function grokSubagentTask( + notification: typeof GrokSubagentNotification.Type, + parentSessionId: string, +): + | { type: "task.started"; payload: TaskStartedPayload } + | { type: "task.completed"; payload: TaskCompletedPayload } + | undefined { + if (notification.sessionId !== parentSessionId) return; + const update = notification.update; + const id = text(update.child_session_id); + if (!id || id === parentSessionId) return; + const linkage = { + taskId: RuntimeTaskId.make(id), + agentId: id, + agentKind: "agent" as const, + timelineBypass: true, + }; + if (update.sessionUpdate === "subagent_spawned" && update.parent_session_id === parentSessionId) { + return { + type: "task.started", + payload: { + ...linkage, + ...(text(update.description).trim() + ? { description: text(update.description).trim(), title: text(update.description).trim() } + : {}), + ...(text(update.model).trim() ? { model: text(update.model).trim() } : {}), + }, + }; + } + if (update.sessionUpdate === "subagent_finished") { + const status = + update.status === "completed" + ? "completed" + : update.status === "failed" + ? "failed" + : "stopped"; + return { + type: "task.completed", + payload: { + ...linkage, + status, + ...(text(update.output).trim() || text(update.error).trim() + ? { summary: text(update.output).trim() || text(update.error).trim() } + : {}), + }, + }; + } +} + +function contentText(value: unknown): string { + if (!Array.isArray(value)) return text(record(value).text); + return value + .map((part) => { + const item = record(part); + return text(item.text) || text(record(item.content).text); + }) + .filter(Boolean) + .join("\n"); +} + +/** Fold ACP chunks and tool updates before paging visible entries. */ +export function grokHistoryEntries( + updates: (typeof Updates.Type)["updates"], + childId: string, +): AgentHistoryEntry[] { + const entries: AgentHistoryEntry[] = []; + const tools = new Map(); + let messageKind: AgentHistoryEntry["kind"] | undefined; + for (const envelope of updates) { + if (envelope.method !== "session/update" || envelope.params.sessionId !== childId) continue; + const update = record(envelope.params.update); + const type = update.sessionUpdate; + const kind = + type === "agent_message_chunk" + ? "assistant" + : type === "user_message_chunk" + ? "user" + : type === "agent_thought_chunk" + ? "reasoning" + : undefined; + if (kind) { + const chunk = contentText(update.content); + if (!chunk) continue; + const previous = entries.at(-1); + if (messageKind === kind && previous) { + entries[entries.length - 1] = { + ...agentHistoryEntry(previous.id, kind, previous.title, previous.detail + chunk), + truncated: previous.truncated || previous.detail.length + chunk.length > 8000, + }; + } else + entries.push( + agentHistoryEntry( + `${childId}:message:${entries.length}`, + kind, + kind === "assistant" ? "Assistant" : kind === "user" ? "User" : "Reasoning", + chunk, + ), + ); + messageKind = kind; + continue; + } + messageKind = undefined; + if (type !== "tool_call" && type !== "tool_call_update") continue; + const id = text(update.toolCallId); + if (!id) continue; + const index = tools.get(id); + const previous = index === undefined ? undefined : entries[index]; + const detail = + contentText(update.content) || + (update.rawOutput !== undefined + ? JSON.stringify(update.rawOutput) + : update.rawInput !== undefined + ? JSON.stringify(update.rawInput) + : (previous?.detail ?? "")); + const normalized = agentHistoryEntry( + `${childId}:tool:${id}`, + "tool", + text(update.title) || previous?.title || "Tool", + detail, + ); + const entry = + previous?.truncated && detail === previous.detail + ? { ...normalized, truncated: true } + : normalized; + if (index === undefined) { + tools.set(id, entries.length); + entries.push(entry); + } else entries[index] = entry; + } + return entries; +} + +export const readGrokAgentHistory = Effect.fn("readGrokAgentHistory")(function* (input: { + parentSessionId: string; + agentId: string; + cwd: string; + offset: number; + view?: "recent-tools" | "latest" | undefined; + request: (method: string, params: unknown) => Effect.Effect; +}) { + const unavailable = (message: string): OrchestrationGetAgentHistoryResult => ({ + status: "unavailable", + entries: [], + nextOffset: null, + message, + }); + if (input.agentId === input.parentSessionId) + return unavailable("Select a child agent to read its history."); + let current = input.agentId; + const seen = new Set(); + while (current !== input.parentSessionId) { + if (seen.has(current) || seen.size >= 32) + return unavailable("Could not verify this agent belongs to the thread."); + seen.add(current); + const state = yield* input + .request("_x.ai/session/state", { sessionId: current, cwd: input.cwd }) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(State))); + if (current === input.agentId && !state.summary.session_kind?.startsWith("subagent")) + return unavailable("The selected session is not a saved subagent."); + if (!state.summary.parent_session_id) + return unavailable("This agent does not belong to the thread."); + current = state.summary.parent_session_id; + } + // The native reader filters rewound branches. Offset is in visible rows, not raw ACP chunks. + const response = yield* input + .request("_x.ai/session/updates", { sessionId: input.agentId, cwd: input.cwd }) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Updates))); + const entries = grokHistoryEntries(response.updates, input.agentId); + const page = collectAgentHistory(input); + for (const entry of entries) { + if (page.add(entry)) break; + } + return page.result(); +}); diff --git a/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts b/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts new file mode 100644 index 000000000000..298e5f4920bc --- /dev/null +++ b/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type { Part } from "@opencode-ai/sdk/v2"; +import { readOpenCodeAgentHistory } from "./openCodeAgentHistory.ts"; + +const parts = Array.from({ length: 53 }, (_, i): Part => ({ + type: "tool", + id: `part-${i}`, + callID: `call-${i}`, + sessionID: "child", + messageID: "message", + tool: "bash", + state: { + status: "completed", + input: { command: "pwd" }, + output: "x".repeat(9000), + title: "pwd", + metadata: {}, + time: { start: 1, end: 2 }, + }, +})); + +it.effect("reads nested children with bounded, stable pages and tool results", () => + Effect.gen(function* () { + const readSession = (id: string) => + Effect.succeed({ id, parentID: id === "child" ? "nested" : "parent" }); + const readMessages = () => + Effect.succeed([{ info: { id: "message", role: "assistant" as const }, parts }]); + const input = { + parentSessionId: "parent", + agentId: "child", + offset: 0, + readSession, + readMessages, + }; + const first = yield* readOpenCodeAgentHistory(input); + expect(first.entries).toHaveLength(50); + expect(first.nextOffset).toBe(50); + expect(first.entries[0]).toMatchObject({ id: "message:part-0", kind: "tool", truncated: true }); + expect(first.entries[0]?.detail).toHaveLength(8000); + const second = yield* readOpenCodeAgentHistory({ ...input, offset: 50 }); + expect(second.entries.map((entry) => entry.id)).toEqual([ + "message:part-50", + "message:part-51", + "message:part-52", + ]); + expect(second.nextOffset).toBeNull(); + }), +); + +for (const scenario of ["unrelated", "cycle", "parent"] as const) { + it.effect(`rejects ${scenario} before reading messages`, () => + Effect.gen(function* () { + const result = yield* readOpenCodeAgentHistory({ + parentSessionId: "parent", + agentId: scenario === "parent" ? "parent" : "child", + offset: 0, + readSession: (id) => + Effect.succeed({ id, ...(scenario === "cycle" ? { parentID: "child" } : {}) }), + readMessages: () => Effect.die("must not read messages"), + }); + expect(result.status).toBe("unavailable"); + }), + ); +} + +it.effect("honors native revert boundaries and maps text, reasoning and failed tools", () => + Effect.gen(function* () { + const base = { sessionID: "child", messageID: "message" }; + const result = yield* readOpenCodeAgentHistory({ + parentSessionId: "parent", + agentId: "child", + offset: 0, + readSession: (id) => + Effect.succeed({ id, parentID: "parent", revert: { messageID: "removed" } }), + readMessages: () => + Effect.succeed([ + { + info: { id: "message", role: "user" as const }, + parts: [ + { ...base, id: "text", type: "text" as const, text: "Prompt" }, + { + ...base, + id: "reason", + type: "reasoning" as const, + text: "Thinking", + time: { start: 1 }, + }, + { + ...base, + id: "error", + callID: "error", + type: "tool" as const, + tool: "bash", + state: { + status: "error" as const, + input: {}, + error: "failed", + time: { start: 1, end: 2 }, + }, + }, + ], + }, + { info: { id: "removed", role: "assistant" as const }, parts }, + ]), + }); + expect(result.entries.map((entry) => entry.kind)).toEqual(["user", "reasoning", "tool"]); + expect(result.entries[2]?.detail).toContain('"error": "failed"'); + }), +); diff --git a/apps/server/src/provider/Layers/openCodeAgentHistory.ts b/apps/server/src/provider/Layers/openCodeAgentHistory.ts new file mode 100644 index 000000000000..22498932f352 --- /dev/null +++ b/apps/server/src/provider/Layers/openCodeAgentHistory.ts @@ -0,0 +1,83 @@ +import type { Message, Part, Session } from "@opencode-ai/sdk/v2"; +import type { AgentHistoryEntry, OrchestrationGetAgentHistoryResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; + +function historyEntry(role: Message["role"], part: Part): AgentHistoryEntry | null { + switch (part.type) { + case "text": + return agentHistoryEntry(part.id, role, role === "user" ? "Prompt" : "Agent", part.text); + case "reasoning": + return agentHistoryEntry(part.id, "reasoning", "Reasoning", part.text); + case "tool": + return agentHistoryEntry( + part.id, + "tool", + part.tool, + JSON.stringify( + { + input: part.state.input, + status: part.state.status, + ...(part.state.status === "completed" ? { output: part.state.output } : {}), + ...(part.state.status === "error" ? { error: part.state.error } : {}), + }, + null, + 2, + ), + ); + default: + return null; + } +} + +/** Read only descendants of the saved session, including nested agents, without resuming them. */ +export const readOpenCodeAgentHistory = Effect.fn("readOpenCodeAgentHistory")(function* (input: { + parentSessionId: string; + agentId: string; + offset: number; + view?: "recent-tools" | "latest" | undefined; + readSession: ( + id: string, + ) => Effect.Effect | undefined, E>; + readMessages: ( + id: string, + ) => Effect.Effect< + ReadonlyArray<{ info: Pick; parts: ReadonlyArray }>, + E + >; +}): Effect.fn.Return { + const seen = new Set([input.parentSessionId]); + let currentId = input.agentId; + let belongsToParent = false; + let revertMessageId: string | undefined; + for (let depth = 0; depth < 32; depth++) { + if (seen.has(currentId)) break; + seen.add(currentId); + const session = yield* input.readSession(currentId); + if (currentId === input.agentId) revertMessageId = session?.revert?.messageID; + if (!session?.parentID) break; + currentId = session.parentID; + if (currentId === input.parentSessionId) { + belongsToParent = true; + break; + } + } + if (!belongsToParent) + return { + status: "unavailable", + entries: [], + nextOffset: null, + message: "This agent does not belong to the saved provider session.", + }; + const messages = yield* input.readMessages(input.agentId); + const page = collectAgentHistory(input); + for (const message of messages) { + if (message.info.id === revertMessageId) break; + for (const part of message.parts) { + const entry = historyEntry(message.info.role, part); + if (entry && page.add({ ...entry, id: `${message.info.id}:${entry.id}` })) + return page.result(); + } + } + return page.result(); +}); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..2ddb1c54c674 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -8,6 +8,8 @@ * @module ProviderAdapter */ import type { + OrchestrationGetAgentHistoryInput, + OrchestrationGetAgentHistoryResult, ApprovalRequestId, ProviderApprovalDecision, ProviderDriverKind, @@ -126,9 +128,15 @@ export interface ProviderAdapterShape { */ readonly hasSession: (threadId: ThreadId) => Effect.Effect; - /** - * Read a provider thread snapshot. - */ + /** Omitted when this provider cannot retrieve saved child history. Never resumes a session. */ + readonly getAgentHistory?: ( + input: OrchestrationGetAgentHistoryInput & { + readonly resumeCursor: unknown; + readonly cwd?: string | undefined; + }, + ) => Effect.Effect; + + /** Read a provider thread snapshot. */ readonly readThread: (threadId: ThreadId) => Effect.Effect; /** diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index c189e2916ff1..57fa298ef81f 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -12,6 +12,8 @@ * @module ProviderService */ import type { + OrchestrationGetAgentHistoryInput, + OrchestrationGetAgentHistoryResult, ProviderInterruptTurnInput, ProviderInstanceId, ProviderRespondToRequestInput, @@ -121,9 +123,12 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; - /** - * Upload a thread and return the provider's shareable feedback identifier. - */ + /** Read saved child history without recovering or resuming the parent session. */ + readonly getAgentHistory: ( + input: OrchestrationGetAgentHistoryInput, + ) => Effect.Effect; + + /** Upload a thread and return the provider's shareable feedback identifier. */ readonly uploadFeedback: ( input: ProviderUploadFeedbackInput, ) => Effect.Effect; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 37fd210ee6da..f30430a036d6 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -68,6 +68,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => assertConversationRollbackSupported: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + getAgentHistory: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }) satisfies ProviderService.ProviderService["Service"]; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..48788241d09e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,7 @@ import { OrchestrationSearchThreadsError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + OrchestrationGetAgentHistoryError, ProjectId, type ProjectEntriesFailure, type ProjectFileFailure, @@ -1378,6 +1379,19 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getAgentHistory]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getAgentHistory, + providerService.getAgentHistory(input).pipe( + Effect.mapError( + () => + new OrchestrationGetAgentHistoryError({ + message: "Could not load agent history from this environment.", + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getWorkflowScript, diff --git a/apps/web/src/components/AgentsPanel.logic.test.ts b/apps/web/src/components/AgentsPanel.logic.test.ts new file mode 100644 index 000000000000..ae3392d8e4d5 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.logic.test.ts @@ -0,0 +1,110 @@ +import { deriveAgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + allPanelAgents, + expansionsAfterCollapse, + finishedAgents, + isLiveAgent, +} from "./AgentsPanel.logic"; + +function agent(overrides: Partial & { id: string }): RuntimeSubagent { + return { + kind: "subagent", + title: overrides.id, + role: null, + model: null, + effort: null, + status: "running", + activationCount: 1, + usage: null, + progress: null, + lastToolName: null, + result: null, + error: null, + outputFile: null, + parentAgentId: null, + agentIndex: null, + phaseIndex: null, + phaseTitle: null, + attempt: null, + workflowName: null, + phases: [], + runHandles: null, + recentActivity: [], + firstSeenAt: "2026-02-23T00:00:00.000Z", + startedAt: "2026-02-23T00:00:00.000Z", + completedAt: null, + updatedAt: "2026-02-23T00:00:00.000Z", + ...overrides, + }; +} + +describe("isLiveAgent", () => { + it("treats idle as settled: a resting agent is not work in flight", () => { + expect(isLiveAgent(agent({ id: "a", status: "running" }))).toBe(true); + expect(isLiveAgent(agent({ id: "b", status: "waiting" }))).toBe(true); + expect(isLiveAgent(agent({ id: "c", status: "idle" }))).toBe(false); + expect(isLiveAgent(agent({ id: "d", status: "completed" }))).toBe(false); + expect(isLiveAgent(agent({ id: "e", status: "failed" }))).toBe(false); + }); +}); + +describe("finishedAgents", () => { + it("collects settled members from every phase and from direct spawns", () => { + const model = deriveAgentPanelModel({ + agents: [ + agent({ id: "wf", kind: "workflow", status: "running" }), + agent({ id: "m1", parentAgentId: "wf", phaseIndex: 0, status: "completed" }), + agent({ id: "m2", parentAgentId: "wf", phaseIndex: 0, status: "running" }), + agent({ id: "m3", parentAgentId: "wf", phaseIndex: 1, status: "failed" }), + agent({ id: "direct-live", status: "running" }), + agent({ id: "direct-done", status: "completed" }), + ], + }); + + expect(finishedAgents(model).map((entry) => entry.id)).toEqual(["m1", "m3", "direct-done"]); + // The coordinator is a container for its members, not a row of its own: + // counting it would report one more agent than is running and double its + // members' usage in the panel total. + expect(allPanelAgents(model).map((entry) => entry.id)).toEqual([ + "m1", + "m2", + "m3", + "direct-done", + "direct-live", + ]); + }); + + it("keeps roster order when an agent settles, so cards never reshuffle", () => { + const before = deriveAgentPanelModel({ + agents: [ + agent({ id: "first", firstSeenAt: "2026-02-23T00:00:00.000Z", status: "completed" }), + agent({ id: "second", firstSeenAt: "2026-02-23T00:00:01.000Z", status: "running" }), + agent({ id: "third", firstSeenAt: "2026-02-23T00:00:02.000Z", status: "completed" }), + ], + }); + const after = deriveAgentPanelModel({ + agents: [ + agent({ id: "first", firstSeenAt: "2026-02-23T00:00:00.000Z", status: "completed" }), + agent({ id: "second", firstSeenAt: "2026-02-23T00:00:01.000Z", status: "completed" }), + agent({ id: "third", firstSeenAt: "2026-02-23T00:00:02.000Z", status: "completed" }), + ], + }); + + expect(finishedAgents(before).map((entry) => entry.id)).toEqual(["first", "third"]); + expect(finishedAgents(after).map((entry) => entry.id)).toEqual(["first", "second", "third"]); + }); +}); + +describe("expansionsAfterCollapse", () => { + it("closes cards inside the collapsed section and leaves the rest open", () => { + const expanded = new Set(["a", "b", "c"]); + const next = expansionsAfterCollapse(expanded, [agent({ id: "b" }), agent({ id: "c" })]); + + expect([...next]).toEqual(["a"]); + // The caller's set is never mutated: React state has to change identity. + expect([...expanded]).toEqual(["a", "b", "c"]); + }); +}); diff --git a/apps/web/src/components/AgentsPanel.logic.ts b/apps/web/src/components/AgentsPanel.logic.ts new file mode 100644 index 000000000000..eca080630bf2 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.logic.ts @@ -0,0 +1,52 @@ +/** + * Roster shape for the Agents panel, kept out of the component so the rules + * that decide where an agent appears can be tested directly. + */ +import type { + AgentPanelModel, + AgentPanelWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import { isTerminalSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; + +/** + * Live = still worth watching. Idle counts as settled: a resting Codex child + * looks done unless resumed, and parking it under Finished keeps the live + * sections honest about what is actually running. + */ +export function isLiveAgent(agent: RuntimeSubagent): boolean { + return !isTerminalSubagentStatus(agent.status) && agent.status !== "idle"; +} + +export function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { + return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; +} + +export function allPanelAgents(model: AgentPanelModel): ReadonlyArray { + return [...model.workflows.flatMap(workflowMembers), ...model.directAgents]; +} + +/** + * Finished agents leave their phase and collect in one section, so the roster + * spends its height on live work. Order is the roster's own (spawn order), + * never re-sorted by outcome — a card must not move because it settled. + */ +export function finishedAgents(model: AgentPanelModel): ReadonlyArray { + return allPanelAgents(model).filter((agent) => !isLiveAgent(agent)); +} + +/** + * Collapsing a section hides its cards, so anything expanded inside it must + * close: reopening the section would otherwise restore a state the user could + * not see themselves leaving behind. + */ +export function expansionsAfterCollapse( + expanded: ReadonlySet, + collapsedMembers: ReadonlyArray, +): ReadonlySet { + const next = new Set(expanded); + for (const member of collapsedMembers) { + next.delete(member.id); + } + return next; +} diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 459506efc78c..fd1fff4304aa 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -3,60 +3,74 @@ * and the ONLY place the roster renders (the chat carries one CTA row per * spawn batch). * - * Visualization rules (from live-test feedback): - * - Spawn order is stable. Activity and completion update rows in place. - * - Agent rows reserve three fixed lines for identity, activity, and metrics; - * changing data must never change their height. - * - Workflow expansion is presentation state. A live run stays expanded when - * it settles; older collapsed runs can still be opened at run granularity. - * - Static status dots, DOM-write elapsed timers, plain token counters. + * Shape, from design review: + * - One agent is one card: title + model/effort + a state chip, over a strip + * carrying the tool it is on right now, its cost, and its clock. + * - Finished agents leave the run and collect under a Finished disclosure, so + * the roster keeps live work at full size. + * - An expanded card reads its latest saved tools; "Open full activity" + * opens the newest history page, with older pages available on demand. + * - State reads as a word, never as colour alone; the clock tints while an + * agent works and the chip names the outcome when it stops. + * - Spawn order is stable. Activity and completion update cards in place. + * - Static status text, DOM-write elapsed timers, one width transition per + * tool change. No continuously repainting animation. */ import { useAtomValue } from "@effect/atom-react"; import type { AgentPanelModel, - AgentPanelWorkflowGroup, RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import { formatSubagentModelLabel, formatSubagentTokenCount, } from "@t3tools/client-runtime/state/subagentRuntime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import type { EnvironmentId, OrchestrationThreadActivity, ThreadId } from "@t3tools/contracts"; +import { Braces, Bot, ChevronDown, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { cn } from "~/lib/utils"; +import { deriveAgentWorkEntries } from "~/session-logic"; +import { useEnvironmentQuery } from "~/state/query"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; +import { workEntryDisplayLabel } from "./chat/MessagesTimeline.logic"; +import { + allPanelAgents, + expansionsAfterCollapse, + finishedAgents, + isLiveAgent, +} from "./AgentsPanel.logic"; /** - * In-flight states all present as Working (one steady state, per the - * monitoring-pill design: detail belongs in the activity sub-line, and a - * stalled/waiting/queued subagent is still the fleet doing its job, not a - * user problem). Only settled states differentiate. + * In-flight states all present as Working (one steady state: a stalled, + * waiting or queued subagent is still the fleet doing its job, not a user + * problem). Only settled states differentiate, and each names its outcome. */ -const STATUS_VISUALS: Record = { - pending: { dotClass: "bg-info", label: "Working" }, - running: { dotClass: "bg-info", label: "Working" }, - waiting: { dotClass: "bg-info", label: "Working" }, - // Idle reads as settled (muted, not sky): a resting Codex child looks done - // unless resumed — live-test: sky idle dots read as stuck in-progress. - idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, - completed: { dotClass: "bg-success", label: "Completed" }, - failed: { dotClass: "bg-destructive", label: "Failed" }, - cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, - interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, +const STATUS_LABELS: Record = { + pending: "Working", + running: "Working", + waiting: "Working", + idle: "Idle", + completed: "Done", + failed: "Failed", + cancelled: "Stopped", + interrupted: "Stopped", }; -function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { - return ( - - ); -} +const CHIP_CLASSES: Record = { + pending: "bg-info/12 text-info-foreground", + running: "bg-info/12 text-info-foreground", + waiting: "bg-info/12 text-info-foreground", + idle: "bg-muted-foreground/12 text-muted-foreground", + completed: "bg-success/14 text-success-foreground", + failed: "bg-destructive/12 text-destructive-foreground", + cancelled: "bg-muted-foreground/12 text-muted-foreground", + interrupted: "bg-muted-foreground/12 text-muted-foreground", +}; + +const isLive = isLiveAgent; function formatElapsedSeconds(totalSeconds: number): string { const seconds = Math.max(0, Math.floor(totalSeconds)); @@ -84,7 +98,7 @@ function elapsedBetween(startedAt: string, endIso: string | null): string { * Elapsed time for the current activation. Live agents self-tick via DOM * writes (zero React commits per tick); settled agents freeze at completedAt. */ -function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { +function AgentElapsed({ agent, className }: { agent: RuntimeSubagent; className?: string }) { const textRef = useRef(null); const live = agent.status === "running" || agent.status === "waiting"; const startedAt = agent.startedAt; @@ -107,155 +121,345 @@ function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { return null; } return ( - + {elapsedBetween(startedAt, live ? null : agent.completedAt)} ); } /** - * Status-dependent activity line. Live rows lead with what is happening now; - * settled rows lead with the outcome. Errors are the only inline previews on - * failed rows because they explain a red row at a glance. + * Swaps a live line's text so the trailing chevron travels to its new position + * instead of teleporting. One width+opacity transition per tool change — a + * handful of frames every few seconds, confined to this inline-block, so + * layout never escapes it. Reduced motion swaps outright. */ -function agentActivityText(agent: RuntimeSubagent): string | null { - const live = - agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; - if (live) { - return ( - agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? - agent.result ?? - agent.error - ); +function GlideText({ text, className }: { text: string; className?: string }) { + const nodeRef = useRef(null); + const timerRef = useRef>(undefined); + + useEffect(() => { + const node = nodeRef.current; + if (!node || node.textContent === text) { + return; + } + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + node.textContent = text; + return; + } + clearTimeout(timerRef.current); + const from = node.getBoundingClientRect().width; + node.style.transition = "none"; + node.style.width = "auto"; + node.textContent = text; + const to = node.getBoundingClientRect().width; + node.style.width = `${from}px`; + node.style.opacity = "0.35"; + void node.offsetWidth; + node.style.transition = "width 260ms cubic-bezier(.2,.7,.3,1), opacity 200ms ease"; + node.style.width = `${to}px`; + node.style.opacity = "1"; + // A change arriving mid-flight orphans transitionend and would leave a + // stale inline width behind, which clips the next line. + timerRef.current = setTimeout(() => { + node.style.transition = ""; + node.style.width = ""; + }, 300); + }, [text]); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + return ( + + {text} + + ); +} + +/** What the agent is on right now, or the last thing it touched. */ +function currentStepText(agent: RuntimeSubagent): string | null { + if (agent.lastToolName) { + return agent.lastToolName; } + return agent.progress ?? agent.recentActivity.at(-1)?.summary ?? null; +} + +/** + * Settled agents lead with their outcome, live ones with what they are doing. + * The two are set differently: a tool line is a machine value and reads as + * mono, a result is the agent's own prose and does not. + */ +function cardSubtitle(agent: RuntimeSubagent): { text: string; mono: boolean } | null { + if (isLive(agent)) { + const step = currentStepText(agent); + return step === null ? null : { text: step, mono: true }; + } + const outcome = agent.error ?? agent.result; + if (outcome !== null) { + return { text: outcome, mono: false }; + } + const step = currentStepText(agent); + return step === null ? null : { text: step, mono: true }; +} + +function AgentChip({ agent }: { agent: RuntimeSubagent }) { return ( - agent.error ?? - agent.result ?? - agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) + + {STATUS_LABELS[agent.status]} + ); } -/** Flat, non-interactive agent status line. No unfold. */ -function AgentRow({ agent }: { agent: RuntimeSubagent }) { - const visuals = STATUS_VISUALS[agent.status]; - const statusLabel = - agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label; - const activity = agentActivityText(agent); - const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); - const role = - agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase() - ? null - : agent.role; - const metadata = [ - modelLabel, - agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok", - agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null, - agent.activationCount > 1 ? `run ${agent.activationCount}` : null, - ].filter((value): value is string => value !== null); +/** + * The agent's own work log, derived only when a card is open. Rows come from + * the same derivation the chat uses, so an agent's tools render like the + * thread's own. + */ +function AgentSteps({ + activities, + agentId, + workspaceRoot, + limit, +}: { + activities: ReadonlyArray; + agentId: string; + workspaceRoot: string | undefined; + limit?: number; +}) { + const entries = useMemo(() => { + const derived = deriveAgentWorkEntries(activities, agentId); + return limit === undefined ? derived : derived.slice(-limit); + }, [activities, agentId, limit]); + + if (entries.length === 0) { + return ( +

+ No recent tool activity is available here. Open full activity to check the agent's saved + history. +

+ ); + } return ( -
- - - - - {agent.title} - {role ? ( - - {role} +
+ {entries.map((entry) => ( +
+ + {workEntryDisplayLabel(entry, workspaceRoot)} - ) : null} - - - - - {agent.status === "completed" ? ( - + {entry.detail ? ( + + {entry.detail} + ) : null} - - - - {activity ?? statusLabel} - - - {metadata.join(" · ")} - - {statusLabel} +
+ ))}
); } -function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { - const status = group.workflow.status; +/** Only expanded cards query saved tools. Hidden windows and in-flight reads do not poll. */ +function RecentAgentTools({ + agent, + environmentId, + threadId, +}: { + agent: RuntimeSubagent; + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const history = useEnvironmentQuery( + orchestrationEnvironment.agentHistory({ + environmentId, + input: { threadId, agentId: agent.id, offset: 0, view: "recent-tools" }, + }), + ); + const refresh = useEffectEvent(() => { + if (document.visibilityState === "visible" && !history.isPending) history.refresh(); + }); + const live = isLiveAgent(agent); + useEffect(() => { + refresh(); + if (!live) return; + const timer = window.setInterval(refresh, 10000); + document.addEventListener("visibilitychange", refresh); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", refresh); + }; + }, [live]); + const entries = history.data?.entries ?? []; return ( - status !== "completed" && - status !== "failed" && - status !== "cancelled" && - status !== "interrupted" +
+ {entries.map((entry) => ( +
+ + {entry.title} + + {entry.detail ? ( + + {entry.detail} + + ) : null} +
+ ))} + {entries.length === 0 ? ( +

+ {history.isPending + ? "Loading recent tools…" + : (history.error ?? history.data?.message ?? "No tool activity yet.")} +

+ ) : null} +
); } -function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { - return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; -} +/** One agent. Opens in place; the full log is a deliberate second step. */ +function AgentCard({ + agent, + activities, + workspaceRoot, + expanded, + onToggle, + onOpen, + environmentId, + threadId, +}: { + agent: RuntimeSubagent; + activities: ReadonlyArray; + workspaceRoot: string | undefined; + expanded: boolean; + onToggle: () => void; + onOpen: () => void; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + const step = cardSubtitle(agent); + const tokens = agent.usage?.totalTokens ?? 0; + const live = isLive(agent); -/** - * Phase rail: the run's shape at a glance. One segment per phase in order, - * separated by chevrons; each segment shows title + one dot per member. - * The whole arc (done → live → pending) is visible without scrolling the - * member list. - */ -function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { - if (group.phases.length === 0) { - return null; - } return ( -
- {group.phases.map((phase, index) => ( -
- {index > 0 ? ( - - ) : null} -
- + + {expanded ? ( +
+
+ {environmentId && threadId ? ( + + ) : ( + + )}
+
- ))} + ) : null} + + ); +} + +/** Collapsible heading. Collapsing also closes anything expanded inside it. */ +function Section({ + title, + meta, + folded, + open, + onToggle, + children, +}: { + title: string; + meta: string; + folded: string; + open: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {open ? children : null}
); } @@ -279,7 +483,7 @@ function WorkflowScriptView({ orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), ); return ( -
+
@@ -311,226 +515,278 @@ function WorkflowScriptView({ ); } -/** - * Collapsible phase section. A phase opens when it becomes active, then keeps - * that shape as it settles so completion never yanks rows out from under the - * user. Manual toggles stick until a later activation begins. - */ -function PhaseSection({ - phase, - defaultOpen = false, +/** History belongs to the selected environment; it is fetched only while this view is open. */ +function AgentHistory({ + agentId, + environmentId, + threadId, }: { - phase: AgentPanelWorkflowGroup["phases"][number]; - defaultOpen?: boolean; + agentId: string; + environmentId: EnvironmentId; + threadId: ThreadId; }) { - const [open, setOpen] = useState(defaultOpen || phase.state === "running"); - const previousState = useRef(phase.state); - + const [offset, setOffset] = useState(null); + const bottom = useRef(null); + const history = useEnvironmentQuery( + orchestrationEnvironment.agentHistory({ + environmentId, + input: { + threadId, + agentId, + offset: offset ?? 0, + ...(offset === null ? { view: "latest" as const } : {}), + }, + }), + ); + const pageOffset = offset ?? history.data?.startOffset ?? 0; useEffect(() => { - if (previousState.current !== "running" && phase.state === "running") { - setOpen(true); - } - previousState.current = phase.state; - }, [phase.state]); - + if (offset === null && history.data && !history.isPending) + bottom.current?.scrollIntoView({ block: "end" }); + }, [offset, history.data, history.isPending]); return ( -
- - {open ? phase.members.map((member) => ) : null} +
+
+ Saved agent activity + +
+ {history.isPending ? ( +

+ Loading activity… +

+ ) : null} + {history.error ? ( +

+ {history.error} +

+ ) : null} + {history.data?.message ? ( +

{history.data.message}

+ ) : null} + {history.data?.status === "ready" && + history.data.entries.length === 0 && + !history.isPending ? ( +

+ No saved activity is available yet. Refresh to check again. +

+ ) : null} + {history.data?.entries.map((entry) => ( +
+

+ {entry.title} +

+ {entry.detail ? ( +
+              {entry.detail}
+            
+ ) : null} + {entry.truncated ? ( +

Long entry shortened.

+ ) : null} +
+ ))} +
+ + +
+
); } -/** Expanded workflow: phase rail + full phase tree. */ -function ExpandedWorkflowSection({ - group, +/** The whole agent: its full work log, its cost, and how to get back. */ +function AgentActivityView({ + agent, environmentId, threadId, - onCollapse, + onBack, }: { - group: AgentPanelWorkflowGroup; + agent: RuntimeSubagent; environmentId: EnvironmentId | null; threadId: ThreadId | null; - onCollapse: () => void; + onBack: () => void; }) { - const [scriptOpen, setScriptOpen] = useState(false); - const members = workflowMembers(group); - const settled = members.filter( - (member) => - member.status === "completed" || - member.status === "failed" || - member.status === "cancelled" || - member.status === "interrupted", - ).length; - const scriptPath = group.workflow.runHandles?.scriptPath; - const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; + const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + const tokens = agent.usage?.totalTokens ?? 0; + const summary = agent.error ?? agent.result; + return ( -
-
- - - {group.workflow.workflowName ?? group.workflow.title} - - {canShowScript ? ( - - ) : null} - - {settled}/{members.length} settled - +
+
+ + {tokens > 0 ? `${formatSubagentTokenCount(tokens)} tok` : "no tokens"} + + {agent.usage?.toolUses !== undefined ? ( + + {agent.usage.toolUses} tools + + ) : null}
- - {scriptOpen && canShowScript ? ( - setScriptOpen(false)} - /> - ) : null} - {group.phases.map((phase) => ( - - ))} - {group.unphasedMembers.map((member) => ( - - ))} - {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( - - ) : null} -
+
+
+

+ {agent.title} +

+ {modelLabel ? ( + + {modelLabel} + + ) : null} +
+
+ + {STATUS_LABELS[agent.status]} + + +
+
+ +
+ {environmentId !== null && threadId !== null ? ( + + ) : ( +

+ Connect to the thread's environment to load agent history. +

+ )} + {summary ? ( +

+ {summary} +

+ ) : null} +
+
+
); } -/** - * Collapsed workflow: one summary line. The parent owns expansion so a live - * workflow keeps its shape when it settles. - */ -function CollapsedWorkflowSection({ - group, - onExpand, -}: { - group: AgentPanelWorkflowGroup; - onExpand: () => void; -}) { - const members = workflowMembers(group); - const failed = members.filter((member) => member.status === "failed").length; - // Coordinator usage may already aggregate members (panel-footer rule): - // count it only when there are no member rows to sum. - const totalTokens = members.reduce( - (sum, member) => sum + (member.usage?.totalTokens ?? 0), - members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, - ); - const elapsed = - group.workflow.startedAt && group.workflow.completedAt - ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) - : null; +/** Progress meter: fixed width, segments share it, so 30 agents read like 5. */ +function FleetMeter({ agents }: { agents: ReadonlyArray }) { return ( -
- -
+ + {agents.map((agent) => ( + + ))} + ); } -/** A workflow's open state is presentation state, not a status derivative. */ -function WorkflowSection({ - group, - environmentId, - threadId, -}: { - group: AgentPanelWorkflowGroup; - environmentId: EnvironmentId | null; - threadId: ThreadId | null; -}) { - const [open, setOpen] = useState(() => workflowIsLive(group)); - return open ? ( - setOpen(false)} - /> - ) : ( - setOpen(true)} /> - ); -} +/** Stable identity: a literal default would break the derivation's memo. */ +const NO_ACTIVITIES: ReadonlyArray = []; export function AgentsPanel({ model, + activities = NO_ACTIVITIES, + workspaceRoot, environmentId = null, threadId = null, }: { model: AgentPanelModel; + activities?: ReadonlyArray; + workspaceRoot?: string | undefined; environmentId?: EnvironmentId | null; threadId?: ThreadId | null; }) { + const [openAgentId, setOpenAgentId] = useState(null); + const [expandedIds, setExpandedIds] = useState>(() => new Set()); + const [collapsedSections, setCollapsedSections] = useState>(() => new Set()); + const [scriptWorkflowId, setScriptWorkflowId] = useState(null); + + const allAgents = useMemo(() => allPanelAgents(model), [model]); + const openAgent = allAgents.find((agent) => agent.id === openAgentId) ?? null; + const finished = useMemo(() => finishedAgents(model), [model]); + + const toggleExpanded = (id: string) => + setExpandedIds((current) => { + const next = new Set(current); + if (!next.delete(id)) next.add(id); + return next; + }); + + const toggleSection = (key: string, members: ReadonlyArray) => + setCollapsedSections((current) => { + const next = new Set(current); + if (!next.delete(key)) { + next.add(key); + setExpandedIds((expanded) => expansionsAfterCollapse(expanded, members)); + } + return next; + }); + + const renderCard = (agent: RuntimeSubagent) => ( + toggleExpanded(agent.id)} + onOpen={() => setOpenAgentId(agent.id)} + /> + ); + if (!model.hasAgents) { return (
@@ -544,42 +800,115 @@ export function AgentsPanel({ ); } + if (openAgent) { + return ( + setOpenAgentId(null)} + /> + ); + } + + const liveDirect = model.directAgents.filter(isLive); + return (
+
+ {model.liveCount > 0 ? ( + {model.liveCount} working + ) : ( + All agents finished + )} + + {model.totalTokens > 0 ? ( + + {formatSubagentTokenCount(model.totalTokens)} tok + + ) : null} +
+ -
- {model.workflows.map((group) => ( - - ))} - {model.directAgents.length > 0 ? ( -
-
- Direct spawns +
+ {model.workflows.map((group) => { + const scriptPath = group.workflow.runHandles?.scriptPath; + const canShowScript = + scriptPath !== undefined && environmentId !== null && threadId !== null; + return ( +
+
+ + {group.workflow.workflowName ?? group.workflow.title} + + {canShowScript ? ( + + ) : null} +
+ {scriptWorkflowId === group.workflow.id && canShowScript ? ( + setScriptWorkflowId(null)} + /> + ) : null} + {group.phases.map((phase) => { + const live = phase.members.filter(isLive); + if (live.length === 0) return null; + const key = `${group.workflow.id}:${phase.index}`; + return ( +
toggleSection(key, phase.members)} + > + {live.map(renderCard)} +
+ ); + })} + {group.unphasedMembers.filter(isLive).map(renderCard)}
- {model.directAgents.map((agent) => ( - - ))} -
+ ); + })} + + {liveDirect.length > 0 ? ( +
+
+ Spawned directly{" "} + {liveDirect.length} +
+ {liveDirect.map(renderCard)} +
+ ) : null} + + {finished.length > 0 ? ( +
toggleSection("finished", finished)} + > + {finished.map(renderCard)} +
) : null}
-
- - {model.runningCount + model.waitingCount > 0 ? ( - - ● {model.runningCount + model.waitingCount} working - - ) : null} - {model.idleCount > 0 ? {model.idleCount} idle : null} - {model.settledCount > 0 ? {model.settledCount} settled : null} - - Σ {formatSubagentTokenCount(model.totalTokens)} tok -
); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..65217a5c82e3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8066,6 +8066,8 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "agents" ? ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 401934bce5ba..ac11a55553f4 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -15,6 +15,7 @@ import { deriveActivePlanState, deriveTimelineEntries, deriveTimelineEntriesWithState, + deriveAgentWorkEntries, deriveWorkLogEntries, findLatestProposedPlan, hasActionableProposedPlan, @@ -2375,3 +2376,103 @@ describe("session activity performance", () => { }); }); }); + +describe("deriveAgentWorkEntries", () => { + it("returns only the tool rows attributed to that agent, in order", () => { + const activities = [ + makeActivity({ + kind: "tool.completed", + summary: "Read", + sequence: 1, + payload: { + itemType: "file_read", + toolCallId: "t1", + title: "Read a.ts", + agentId: "agent-1", + }, + }), + makeActivity({ + kind: "tool.completed", + summary: "Read", + sequence: 2, + payload: { + itemType: "file_read", + toolCallId: "t2", + title: "Read b.ts", + agentId: "agent-2", + }, + }), + makeActivity({ + kind: "tool.completed", + summary: "Read", + sequence: 3, + payload: { itemType: "file_read", toolCallId: "t3", title: "Read c.ts" }, + }), + makeActivity({ + kind: "tool.completed", + summary: "Grep", + sequence: 4, + payload: { itemType: "file_search", toolCallId: "t4", title: "Grep x", agentId: "agent-1" }, + }), + ]; + + expect(deriveAgentWorkEntries(activities, "agent-1").map((entry) => entry.toolTitle)).toEqual([ + "Read a.ts", + "Grep x", + ]); + expect(deriveAgentWorkEntries(activities, "agent-2").map((entry) => entry.toolTitle)).toEqual([ + "Read b.ts", + ]); + }); + + it("collapses a tool's in-progress row into its completed row", () => { + const entries = deriveAgentWorkEntries( + [ + makeActivity({ + kind: "tool.updated", + summary: "Bash", + sequence: 1, + payload: { + itemType: "command_execution", + toolCallId: "t1", + title: "vp test", + status: "in_progress", + agentId: "agent-1", + }, + }), + makeActivity({ + kind: "tool.completed", + summary: "Bash", + sequence: 2, + payload: { + itemType: "command_execution", + toolCallId: "t1", + title: "vp test", + status: "completed", + agentId: "agent-1", + }, + }), + ], + "agent-1", + ); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ toolCallId: "t1", toolLifecycleStatus: "completed" }); + }); + + it("ignores an agent's own task rows: the roster already reports them", () => { + const entries = deriveAgentWorkEntries( + [ + makeActivity({ + kind: "task.progress", + summary: "Working", + sequence: 1, + payload: { taskId: "agent-1", description: "Working", agentKind: "agent" }, + }), + ], + "agent-1", + ); + + expect(entries).toEqual([]); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f84c978a04c8..f92fa3ccd82b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -472,6 +472,36 @@ export function deriveWorkLogEntries( return collapseDerivedWorkLogEntries(entries); } +/** + * Work log for a single agent: the tool rows the thread's own work log + * deliberately hides. isAgentInternalActivity drops anything carrying + * `payload.agentId` from the parent timeline (quiet-timeline guarantee), and + * this is where those rows are re-homed. Derivation is shared with the parent + * work log, so an agent's tools render exactly like the thread's own. + * + * Callers derive this lazily, for the one agent a user opened — the roster + * never needs it. + */ +export function deriveAgentWorkEntries( + activities: ReadonlyArray, + agentId: string, +): WorkLogEntry[] { + const entries: DerivedWorkLogEntry[] = []; + for (const activity of [...activities].toSorted(compareActivitiesByOrder)) { + // tool.started is superseded by the updated/completed row for the same + // toolCallId, exactly as in the parent work log. + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") continue; + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + if (!payload || payload.agentId !== agentId) continue; + if (isPlanBoundaryToolActivity(activity)) continue; + entries.push(toDerivedWorkLogEntry(activity)); + } + return collapseDerivedWorkLogEntries(entries); +} + /** Adapters forward unknown wire-only SDK messages (background_tasks_changed, * commands_changed, ...) as runtime warnings. The suffix comes from * describeUnknownSdkMessage in the Claude adapter; a row with no displayable diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 6d3ee3b16300..353f28385d8e 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -99,3 +99,12 @@ config directory and put the router's endpoint and credential variables in that instance's **Environment variables**. The router must run where the environment can reach it. Follow the [Claude Code Router instructions](https://github.com/musistudio/claude-code-router) for its installation and routing configuration. + +## Subagent history + +On web and desktop, open the Agents panel, expand a subagent, and choose +**Open full activity** to read its saved messages and tool activity. History +comes from the configured Claude instance on the connected computer and can +remain available after the session stops. Opening it does not resume Claude. +Use **Refresh** for newly saved activity and **Next** for subsequent pages. +Long entries are shortened and marked. diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 06c59c6f6aee..645deb8908af 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -78,3 +78,13 @@ In an existing Codex thread, send `/feedback` with an optional description, for example `/feedback The agent stopped before finishing the tests`. This uploads the conversation and Codex logs to OpenAI. The returned thread ID can be shared with OpenAI support. + +## Subagent history + +On web and desktop, open the Agents panel, expand a subagent, and choose +**Open full activity** to read its saved conversation and tool activity. This +also works after the session stops, provided Codex still has its saved history +on the connected computer. Opening history does not resume the agent. + +Use **Refresh** for newly saved activity and **Next** for subsequent pages. +Long entries are shortened and marked. Claude also supports this history view. diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 23de99798956..61f392e8f9d5 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -20,6 +20,17 @@ fail, check the URL, credentials, and OpenCode version, then refresh provider st After a lost connection, send another prompt to reconnect to the same OpenCode session. +## Agent history + +In the web or desktop Agents panel, expand an agent and choose **Open full +activity** to read its saved conversation and tool activity. History comes from +the OpenCode server configured for that environment and can be read after the +agent stops. Opening history does not resume the agent. + +Use **Refresh** for newer activity and **Next** for more entries. Agents created +before T3 Code began tracking OpenCode child sessions do not appear retroactively +in the panel. + ## Approvals OpenCode follows the shared [permission modes](./permission-modes.md). **Auto** has diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index ba80275bffb3..daedfdccacc9 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -12,6 +12,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:turn-diff", tag: ORCHESTRATION_WS_METHODS.getTurnDiff, }), + agentHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:agent-history", + tag: ORCHESTRATION_WS_METHODS.getAgentHistory, + staleTimeMs: 0, + idleTtlMs: 0, + }), workflowScript: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:workflow-script", tag: ORCHESTRATION_WS_METHODS.getWorkflowScript, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4d2f80a1101a..603548851d0c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,6 +27,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getWorkflowScript: "orchestration.getWorkflowScript", + getAgentHistory: "orchestration.getAgentHistory", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", @@ -1958,6 +1959,38 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({ }); export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; +/** Provider history is read on demand, independently of retained thread activities. */ +export const OrchestrationGetAgentHistoryInput = Schema.Struct({ + threadId: ThreadId, + agentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + offset: NonNegativeInt, + view: Schema.optional(Schema.Literals(["recent-tools", "latest"])), +}); +export type OrchestrationGetAgentHistoryInput = typeof OrchestrationGetAgentHistoryInput.Type; + +export const AgentHistoryEntry = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals(["tool", "assistant", "user", "reasoning"]), + title: Schema.String.check(Schema.isMaxLength(500)), + detail: Schema.String.check(Schema.isMaxLength(8000)), + truncated: Schema.Boolean, +}); +export type AgentHistoryEntry = typeof AgentHistoryEntry.Type; + +export const OrchestrationGetAgentHistoryResult = Schema.Struct({ + status: Schema.Literals(["ready", "unavailable", "unsupported"]), + entries: Schema.Array(AgentHistoryEntry).check(Schema.isMaxLength(50)), + nextOffset: Schema.NullOr(NonNegativeInt), + startOffset: Schema.optional(NonNegativeInt), + message: Schema.NullOr(Schema.String), +}); +export type OrchestrationGetAgentHistoryResult = typeof OrchestrationGetAgentHistoryResult.Type; + +export class OrchestrationGetAgentHistoryError extends Schema.TaggedError()( + "OrchestrationGetAgentHistoryError", + { message: Schema.String }, +) {} + export const OrchestrationGetWorkflowScriptInput = Schema.Struct({ threadId: ThreadId, /** Absolute path from the workflow's runHandles.scriptPath. The server @@ -2011,6 +2044,10 @@ export const OrchestrationRpcSchemas = { input: ClientOrchestrationCommand, output: DispatchResult, }, + getAgentHistory: { + input: OrchestrationGetAgentHistoryInput, + output: OrchestrationGetAgentHistoryResult, + }, getWorkflowScript: { input: OrchestrationGetWorkflowScriptInput, output: OrchestrationGetWorkflowScriptResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..b135694c7915 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -92,6 +92,7 @@ import { OrchestrationGetTurnDiffInput, OrchestrationRpcSchemas, OrchestrationGetWorkflowScriptError, + OrchestrationGetAgentHistoryError, } from "./orchestration.ts"; import { ProviderUploadFeedbackError, @@ -1068,6 +1069,12 @@ const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.disp error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +const WsOrchestrationGetAgentHistoryRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getAgentHistory, { + payload: OrchestrationRpcSchemas.getAgentHistory.input, + success: OrchestrationRpcSchemas.getAgentHistory.output, + error: Schema.Union([OrchestrationGetAgentHistoryError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationGetWorkflowScriptRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getWorkflowScript, { payload: OrchestrationRpcSchemas.getWorkflowScript.input, success: OrchestrationRpcSchemas.getWorkflowScript.output, @@ -1297,6 +1304,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, + WsOrchestrationGetAgentHistoryRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationSearchThreadsRpc, From a96d035e923c1433d4977c38e96e371dbe2cd48e Mon Sep 17 00:00:00 2001 From: AKolenda <91154044+AKolenda@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:14:43 -0600 Subject: [PATCH 2/4] fix(codex): render available reasoning text without empty cards --- .../provider/Layers/codexAgentHistory.test.ts | 23 ++++++++++++++++++- .../src/provider/Layers/codexAgentHistory.ts | 10 +++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/codexAgentHistory.test.ts b/apps/server/src/provider/Layers/codexAgentHistory.test.ts index 10bc04f1ad3d..12a6a718e3c1 100644 --- a/apps/server/src/provider/Layers/codexAgentHistory.test.ts +++ b/apps/server/src/provider/Layers/codexAgentHistory.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import type { V2ThreadReadResponse } from "effect-codex-app-server/schema"; import { OrchestrationGetAgentHistoryResult } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { readCodexAgentHistory } from "./codexAgentHistory.ts"; +import { codexHistoryEntry, readCodexAgentHistory } from "./codexAgentHistory.ts"; const isAgentHistoryResult = Schema.is(OrchestrationGetAgentHistoryResult); @@ -45,6 +45,27 @@ function thread(id: string, parent: string | null, count = 1): V2ThreadReadRespo } describe("saved Codex agent history", () => { + it("uses native reasoning text when no summary is supplied and omits empty markers", () => { + expect( + codexHistoryEntry({ + type: "reasoning", + id: "r", + summary: [], + content: ["Checking the schema."], + }), + ).toMatchObject({ title: "Reasoning", detail: "Checking the schema." }); + expect( + codexHistoryEntry({ + type: "reasoning", + id: "r", + summary: ["Reviewing validation."], + content: ["Other text"], + }), + ).toMatchObject({ title: "Reasoning summary", detail: "Reviewing validation." }); + expect( + codexHistoryEntry({ type: "reasoning", id: "r", summary: [" "], content: [] }), + ).toBeNull(); + }); it.effect("reads a stopped nested child's history with bounded, nonoverlapping pages", () => Effect.gen(function* () { const calls: Array<[string, boolean]> = []; diff --git a/apps/server/src/provider/Layers/codexAgentHistory.ts b/apps/server/src/provider/Layers/codexAgentHistory.ts index 3e413e26d217..ebc00bb00462 100644 --- a/apps/server/src/provider/Layers/codexAgentHistory.ts +++ b/apps/server/src/provider/Layers/codexAgentHistory.ts @@ -20,13 +20,17 @@ export function codexHistoryEntry( ); case "agentMessage": return agentHistoryEntry(item.id, "assistant", "Agent", item.text); - case "reasoning": + case "reasoning": { + const summary = (item.summary ?? []).join("\n").trim(); + const content = (item.content ?? []).join("\n").trim(); + if (!summary && !content) return null; return agentHistoryEntry( item.id, "reasoning", - "Reasoning summary", - (item.summary ?? []).join("\n"), + summary ? "Reasoning summary" : "Reasoning", + summary || content, ); + } case "plan": return agentHistoryEntry(item.id, "assistant", "Plan", item.text); case "commandExecution": From ef82db088e917262bc4b12ed9f201270f466dcc8 Mon Sep 17 00:00:00 2001 From: AKolenda <91154044+AKolenda@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:37:24 -0600 Subject: [PATCH 3/4] fix(web): collapse saved agent tool output and label file edits --- .../src/provider/Layers/agentHistory.test.ts | 5 ++ .../src/provider/Layers/agentHistory.ts | 2 +- .../Layers/claudeAgentHistory.test.ts | 16 +++++ .../src/provider/Layers/claudeAgentHistory.ts | 16 ++++- .../provider/Layers/codexAgentHistory.test.ts | 16 +++++ .../src/provider/Layers/codexAgentHistory.ts | 4 +- .../provider/Layers/grokAgentHistory.test.ts | 15 +++++ .../src/provider/Layers/grokAgentHistory.ts | 2 +- .../Layers/openCodeAgentHistory.test.ts | 36 ++++++++++ .../provider/Layers/openCodeAgentHistory.ts | 9 ++- apps/web/src/components/AgentsPanel.tsx | 66 ++++++++++++------- packages/contracts/src/orchestration.ts | 2 +- 12 files changed, 153 insertions(+), 36 deletions(-) diff --git a/apps/server/src/provider/Layers/agentHistory.test.ts b/apps/server/src/provider/Layers/agentHistory.test.ts index b4e575fa493a..be63ea776800 100644 --- a/apps/server/src/provider/Layers/agentHistory.test.ts +++ b/apps/server/src/provider/Layers/agentHistory.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect } from "@effect/vitest"; import { agentHistoryEntry, collectAgentHistory } from "./agentHistory.ts"; describe("agent history selection", () => { + it("keeps file edits in recent tools", () => { + const page = collectAgentHistory({ offset: 0, view: "recent-tools" }); + page.add(agentHistoryEntry("edit", "file-edit", "Edit X.jsx", "patch")); + expect(page.result().entries.map((entry) => entry.id)).toEqual(["edit"]); + }); const entries = Array.from({ length: 130 }, (_, index) => agentHistoryEntry( String(index), diff --git a/apps/server/src/provider/Layers/agentHistory.ts b/apps/server/src/provider/Layers/agentHistory.ts index 64df430447a2..5567807478a2 100644 --- a/apps/server/src/provider/Layers/agentHistory.ts +++ b/apps/server/src/provider/Layers/agentHistory.ts @@ -36,7 +36,7 @@ export function collectAgentHistory( return false; } if (input.view === "recent-tools") { - if (entry.kind !== "tool") return false; + if (entry.kind !== "tool" && entry.kind !== "file-edit") return false; entries.push({ ...entry, detail: entry.detail.slice(0, 240), diff --git a/apps/server/src/provider/Layers/claudeAgentHistory.test.ts b/apps/server/src/provider/Layers/claudeAgentHistory.test.ts index 34d3da438f47..ea179608bffc 100644 --- a/apps/server/src/provider/Layers/claudeAgentHistory.test.ts +++ b/apps/server/src/provider/Layers/claudeAgentHistory.test.ts @@ -57,6 +57,22 @@ const read = (agentId = "child", offset = 0) => readClaudeAgentHistory({ configDir, sessionId, agentId, offset }); describe("Claude saved agent history", () => { + it("identifies file edits without putting patch content in the title", async () => { + await save("child", [ + "prompt", + [ + { + type: "tool_use", + id: "edit", + name: "Edit", + input: { file_path: "src/X.jsx", old_string: "old", new_string: "new" }, + }, + ], + ]); + const result = await read(); + expect(result.entries[1]).toMatchObject({ kind: "file-edit", title: "Edit src/X.jsx" }); + expect(result.entries[1]?.detail).toContain("new_string"); + }); it("reads nested transcripts, preserves calls and results, and bounds entry detail", async () => { await save( "child", diff --git a/apps/server/src/provider/Layers/claudeAgentHistory.ts b/apps/server/src/provider/Layers/claudeAgentHistory.ts index 06159f3ba165..610b99c5d653 100644 --- a/apps/server/src/provider/Layers/claudeAgentHistory.ts +++ b/apps/server/src/provider/Layers/claudeAgentHistory.ts @@ -123,15 +123,25 @@ export function claudeHistoryEntries(message: SessionMessage): AgentHistoryEntry return block.thinking ? [agentHistoryEntry(id, "reasoning", "Reasoning", block.thinking)] : []; - case "tool_use": + case "tool_use": { + const fileEdit = + block.name === "Edit" || block.name === "Write" || block.name === "MultiEdit"; + const path = + block.input && + typeof block.input === "object" && + "file_path" in block.input && + typeof block.input.file_path === "string" + ? block.input.file_path + : null; return [ agentHistoryEntry( id, - "tool", - block.name ?? "Tool", + fileEdit ? "file-edit" : "tool", + fileEdit && path ? `Edit ${path}` : (block.name ?? "Tool"), JSON.stringify(block.input ?? {}, null, 2), ), ]; + } case "tool_result": return [ agentHistoryEntry( diff --git a/apps/server/src/provider/Layers/codexAgentHistory.test.ts b/apps/server/src/provider/Layers/codexAgentHistory.test.ts index 12a6a718e3c1..c18884831ccc 100644 --- a/apps/server/src/provider/Layers/codexAgentHistory.test.ts +++ b/apps/server/src/provider/Layers/codexAgentHistory.test.ts @@ -45,6 +45,22 @@ function thread(id: string, parent: string | null, count = 1): V2ThreadReadRespo } describe("saved Codex agent history", () => { + it("labels file edits with their paths while preserving the patch for expansion", () => { + expect( + codexHistoryEntry({ + type: "fileChange", + id: "edit", + status: "completed", + changes: [ + { path: "src/X.jsx", kind: { type: "update", movePath: null }, diff: "-old\n+new" }, + ], + }), + ).toMatchObject({ + kind: "file-edit", + title: "Edit src/X.jsx", + detail: "src/X.jsx\n-old\n+new", + }); + }); it("uses native reasoning text when no summary is supplied and omits empty markers", () => { expect( codexHistoryEntry({ diff --git a/apps/server/src/provider/Layers/codexAgentHistory.ts b/apps/server/src/provider/Layers/codexAgentHistory.ts index ebc00bb00462..c73098559e59 100644 --- a/apps/server/src/provider/Layers/codexAgentHistory.ts +++ b/apps/server/src/provider/Layers/codexAgentHistory.ts @@ -50,8 +50,8 @@ export function codexHistoryEntry( case "fileChange": return agentHistoryEntry( item.id, - "tool", - "File changes", + "file-edit", + `Edit ${item.changes.map((change) => change.path).join(", ")}`, item.changes.map((change) => `${change.path}\n${change.diff}`).join("\n\n"), ); case "mcpToolCall": diff --git a/apps/server/src/provider/Layers/grokAgentHistory.test.ts b/apps/server/src/provider/Layers/grokAgentHistory.test.ts index 4bcc7f9e00a0..68741723d90d 100644 --- a/apps/server/src/provider/Layers/grokAgentHistory.test.ts +++ b/apps/server/src/provider/Layers/grokAgentHistory.test.ts @@ -7,6 +7,21 @@ const envelope = (update: Record, sessionId = "child") => ({ params: { sessionId, update }, }); describe("Grok saved child history", () => { + it("retains edit classification through result-only updates", () => { + const entries = grokHistoryEntries( + [ + envelope({ + sessionUpdate: "tool_call", + toolCallId: "edit", + kind: "edit", + title: "Edit X.jsx", + }), + envelope({ sessionUpdate: "tool_call_update", toolCallId: "edit", rawOutput: "patch" }), + ], + "child", + ); + expect(entries[0]).toMatchObject({ kind: "file-edit", title: "Edit X.jsx" }); + }); it("folds chunks and tool results, excluding unrelated sessions", () => { const entries = grokHistoryEntries( [ diff --git a/apps/server/src/provider/Layers/grokAgentHistory.ts b/apps/server/src/provider/Layers/grokAgentHistory.ts index 436fab0d1aaf..eb7534fe8eb3 100644 --- a/apps/server/src/provider/Layers/grokAgentHistory.ts +++ b/apps/server/src/provider/Layers/grokAgentHistory.ts @@ -148,7 +148,7 @@ export function grokHistoryEntries( : (previous?.detail ?? "")); const normalized = agentHistoryEntry( `${childId}:tool:${id}`, - "tool", + update.kind === "edit" || previous?.kind === "file-edit" ? "file-edit" : "tool", text(update.title) || previous?.title || "Tool", detail, ); diff --git a/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts b/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts index 298e5f4920bc..d7df751bc41a 100644 --- a/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts +++ b/apps/server/src/provider/Layers/openCodeAgentHistory.test.ts @@ -108,3 +108,39 @@ it.effect("honors native revert boundaries and maps text, reasoning and failed t expect(result.entries[2]?.detail).toContain('"error": "failed"'); }), ); + +it.effect("labels edits by file path", () => + Effect.gen(function* () { + const result = yield* readOpenCodeAgentHistory({ + parentSessionId: "parent", + agentId: "child", + offset: 0, + readSession: (id) => Effect.succeed({ id, parentID: "parent" }), + readMessages: () => + Effect.succeed([ + { + info: { id: "message", role: "assistant" as const }, + parts: [ + { + type: "tool" as const, + id: "edit", + callID: "edit", + sessionID: "child", + messageID: "message", + tool: "edit", + state: { + status: "completed" as const, + input: { filePath: "src/X.jsx", newString: "new" }, + output: "patched", + title: "edit", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + ], + }, + ]), + }); + expect(result.entries[0]).toMatchObject({ kind: "file-edit", title: "Edit src/X.jsx" }); + }), +); diff --git a/apps/server/src/provider/Layers/openCodeAgentHistory.ts b/apps/server/src/provider/Layers/openCodeAgentHistory.ts index 22498932f352..bdebf8f57bac 100644 --- a/apps/server/src/provider/Layers/openCodeAgentHistory.ts +++ b/apps/server/src/provider/Layers/openCodeAgentHistory.ts @@ -9,11 +9,13 @@ function historyEntry(role: Message["role"], part: Part): AgentHistoryEntry | nu return agentHistoryEntry(part.id, role, role === "user" ? "Prompt" : "Agent", part.text); case "reasoning": return agentHistoryEntry(part.id, "reasoning", "Reasoning", part.text); - case "tool": + case "tool": { + const fileEdit = part.tool === "edit" || part.tool === "write" || part.tool === "apply_patch"; + const path = part.state.input.filePath ?? part.state.input.file_path; return agentHistoryEntry( part.id, - "tool", - part.tool, + fileEdit ? "file-edit" : "tool", + fileEdit && typeof path === "string" ? `Edit ${path}` : part.tool, JSON.stringify( { input: part.state.input, @@ -25,6 +27,7 @@ function historyEntry(role: Message["role"], part: Part): AgentHistoryEntry | nu 2, ), ); + } default: return null; } diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index fd1fff4304aa..d0e0735f4486 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -312,7 +312,7 @@ function RecentAgentTools({ {entry.title} - {entry.detail ? ( + {entry.detail && entry.kind !== "file-edit" ? ( {entry.detail} @@ -576,31 +576,47 @@ function AgentHistory({ No saved activity is available yet. Refresh to check again.

) : null} - {history.data?.entries.map((entry) => ( -
-

{ + const tool = entry.kind === "tool" || entry.kind === "file-edit"; + const detail = ( + <> + {entry.detail ? ( +

+                {entry.detail}
+              
+ ) : null} + {entry.truncated ? ( +

Long entry shortened.

+ ) : null} + + ); + return tool ? ( +
- {entry.title} -

- {entry.detail ? ( -
-              {entry.detail}
-            
- ) : null} - {entry.truncated ? ( -

Long entry shortened.

- ) : null} -
- ))} + + {entry.title} + + {detail} + + ) : ( +
+

+ {entry.title} +

+ {detail} +
+ ); + })}