diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 1cd5ef0a..539b39c1 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -31,6 +31,7 @@ export const ACP_EXT_SESSION_RATE_LIMITS_METHOD = "_acp_ext:session_rate_limits" export const ACP_EXT_CODEX_PROPOSED_PLAN_METHOD = "_acp_ext:codex_proposed_plan"; export const CODEX_STEER_APPLIED_METHOD = "_codex/steerApplied"; export const SESSION_STEERING_METHOD = "_session/steering"; +export const LODY_READ_SESSION_HISTORY_METHOD = "_lody/session/history/read"; export function getLodyForkTurnId(meta: unknown): string | null { if (typeof meta !== "object" || meta === null) return null; const lody = (meta as Record)["lody"]; @@ -60,6 +61,22 @@ export const CODEX_STEER_CAPABILITY: CodexSteerCapability = { configPolicy: "active", }; +export type LodyReadSessionHistoryCapability = { + version: 1; + method: typeof LODY_READ_SESSION_HISTORY_METHOD; +} + +export const LODY_READ_SESSION_HISTORY_CAPABILITY: LodyReadSessionHistoryCapability = { + version: 1, + method: LODY_READ_SESSION_HISTORY_METHOD, +}; + +export type LodyReadSessionHistoryRequest = { + sessionId: SessionId; +} + +export type LodyReadSessionHistoryResponse = {} + export type LegacySessionModel = { modelId: string; name: string; diff --git a/src/CodexAcpApp.ts b/src/CodexAcpApp.ts index ac965db1..ddc580e4 100644 --- a/src/CodexAcpApp.ts +++ b/src/CodexAcpApp.ts @@ -3,6 +3,7 @@ import {z} from "zod"; import type {CodexAcpServer} from "./CodexAcpServer"; import { LEGACY_SET_SESSION_MODEL_METHOD, + LODY_READ_SESSION_HISTORY_METHOD, SESSION_STEERING_METHOD, } from "./AcpExtensions"; import {registerGoalControlRequests} from "./GoalControlTransport"; @@ -23,6 +24,10 @@ const sessionSteerParamsParser = z.object({ steerId: z.string().min(1).optional(), }).passthrough(); +const lodyReadSessionHistoryParamsParser = z.object({ + sessionId: z.string().min(1), +}).passthrough(); + export interface CodexAcpAppOptions { name: string; createAgent: (connection: acp.AgentContext) => CodexAcpServer; @@ -67,6 +72,7 @@ export function createCodexAcpApp(options: CodexAcpAppOptions): acp.AgentApp { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(LODY_READ_SESSION_HISTORY_METHOD, lodyReadSessionHistoryParamsParser, (ctx) => getAgent().readSessionHistory(ctx.params)) .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)); return registerGoalControlRequests(agentApp, getAgent); diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 48c3932e..260a416e 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -450,6 +450,14 @@ export class CodexAcpClient { return this.codexClient.accountRateLimitsRead(); } + async readSessionHistory(sessionId: string): Promise { + const response = await this.codexClient.threadRead({ + threadId: sessionId, + includeTurns: true, + }); + return response.thread; + } + async resumeSession( request: acp.ResumeSessionRequest, onSubscribed?: (sessionId?: string) => void, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 7d63ad5b..c52f5856 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -63,12 +63,15 @@ import { isExtMethodRequest, LEGACY_GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + LODY_READ_SESSION_HISTORY_CAPABILITY, type LegacyLoadSessionResponse, type LegacyNewSessionResponse, type LegacyResumeSessionResponse, type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + type LodyReadSessionHistoryRequest, + type LodyReadSessionHistoryResponse, SESSION_STEERING_METHOD, type SessionSteerRequest, type SessionSteeringResponse, @@ -160,6 +163,11 @@ export interface SessionState { sessionFailure?: SessionFailure; } +type HistoryProjectionState = Pick< + SessionState, + "sessionId" | "terminalOutputMode" | "sessionTitle" | "sessionTitleSource" +>; + export type SessionFailureCategory = | "connection" | "access" | "limit" | "request" | "service" | "unknown"; @@ -379,6 +387,7 @@ export class CodexAcpServer { }, lody: { forkAtTurn: {version: 1}, + readSessionHistory: LODY_READ_SESSION_HISTORY_CAPABILITY, }, }, }, @@ -843,6 +852,27 @@ export class CodexAcpServer { }; } + async readSessionHistory( + params: LodyReadSessionHistoryRequest, + ): Promise { + if (this.providerUpdate !== null) { + await this.providerUpdate; + } + logger.log("Reading session history...", {sessionId: params.sessionId}); + const thread = await this.runWithProcessCheck( + () => this.codexAcpClient.readSessionHistory(params.sessionId), + ); + const historyState: HistoryProjectionState = { + sessionId: params.sessionId, + terminalOutputMode: this.terminalOutputMode, + sessionTitle: null, + sessionTitleSource: "unset", + }; + await this.streamThreadHistory(params.sessionId, thread, historyState); + logger.log("Session history read", {sessionId: params.sessionId}); + return {}; + } + async resumeSession(params: acp.ResumeSessionRequest): Promise { if (this.providerUpdate !== null) { await this.providerUpdate; @@ -1871,19 +1901,22 @@ export class CodexAcpServer { }; } - private async streamThreadHistory(sessionId: string, thread: Thread): Promise { + private async streamThreadHistory( + sessionId: string, + thread: Thread, + projectionState: HistoryProjectionState = this.getSessionState(sessionId), + ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); - const sessionState = this.getSessionState(sessionId); - await this.publishThreadHistoryTitle(session, sessionState, thread); + await this.publishThreadHistoryTitle(session, projectionState, thread); const responseItemFallbackUpdates = await createResponseItemHistoryFallbackUpdates( thread, - sessionState.terminalOutputMode, + projectionState.terminalOutputMode, ); const threadUpdates: UpdateSessionEvent[] = []; for (const turn of thread.turns) { for (const item of turn.items) { - const updates = await this.createHistoryUpdates(item, sessionState, turn.id); + const updates = await this.createHistoryUpdates(item, projectionState, turn.id); threadUpdates.push(...updates); } } @@ -1898,7 +1931,7 @@ export class CodexAcpServer { private async publishThreadHistoryTitle( session: ACPSessionConnection, - sessionState: SessionState, + sessionState: HistoryProjectionState, thread: Thread, ): Promise { const explicitTitle = this.normalizeSessionTitle(thread.name); @@ -1937,7 +1970,7 @@ export class CodexAcpServer { } private async publishFallbackSessionTitle( - sessionState: SessionState, + sessionState: HistoryProjectionState, title: string | null, ): Promise { if (sessionState.sessionTitleSource !== "unset" || !title) return; @@ -2014,7 +2047,7 @@ export class CodexAcpServer { private async createHistoryUpdates( item: ThreadItem, - sessionState: SessionState, + sessionState: Pick, turnId: string, ): Promise { switch (item.type) { diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 57dd7a0f..3d4d43b0 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -74,6 +74,10 @@ describe('CodexACPAgent - initialize', () => { forkAtTurn: { version: 1, }, + readSessionHistory: { + version: 1, + method: "_lody/session/history/read", + }, }, }, }, diff --git a/src/__tests__/CodexACPAgent/read-session-history.test.ts b/src/__tests__/CodexACPAgent/read-session-history.test.ts new file mode 100644 index 00000000..01c311ca --- /dev/null +++ b/src/__tests__/CodexACPAgent/read-session-history.test.ts @@ -0,0 +1,121 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Thread} from "../../app-server/v2"; +import {createCodexMockTestFixture} from "../acp-test-utils"; + +describe("CodexACPAgent - readSessionHistory", () => { + it("reads and projects history without resuming or installing the session", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const appServer = fixture.getCodexAppServerClient(); + const threadResume = vi.spyOn(appServer, "threadResume") + .mockRejectedValue(new Error("thread already has an active writer")); + const threadRead = vi.spyOn(appServer, "threadRead").mockResolvedValue({ + thread: createHistoryThread(), + }); + + await expect(agent.readSessionHistory({sessionId: "session-1"})).resolves.toEqual({}); + + expect(threadRead).toHaveBeenCalledOnce(); + expect(threadRead).toHaveBeenCalledWith({ + threadId: "session-1", + includeTurns: true, + }); + expect(threadResume).not.toHaveBeenCalled(); + expect(() => agent.getSessionState("session-1")).toThrow("Session session-1 not found"); + expect(fixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate") + .map(event => event.args[0])) + .toEqual([ + { + sessionId: "session-1", + update: { + sessionUpdate: "session_info_update", + title: "Imported conversation", + _meta: { + codex: { + titleSource: "explicit", + }, + }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { + type: "text", + text: "Hello", + }, + }, + }, + { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "agent-1", + content: { + type: "text", + text: "Hi there", + }, + _meta: { + lody: { + turnId: "turn-1", + }, + }, + }, + }, + ]); + }); +}); + +function createHistoryThread(): Thread { + return { + id: "session-1", + sessionId: "session-1", + forkedFromId: null, + parentThreadId: null, + preview: "Hello", + ephemeral: false, + section: null, + sectionEnteredAt: null, + modelProvider: "openai", + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd: "/repo/project", + cliVersion: "0.0.0", + source: "cli", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: "Imported conversation", + turns: [{ + id: "turn-1", + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items: [ + { + type: "userMessage", + id: "user-1", + clientId: null, + content: [{type: "text", text: "Hello", text_elements: []}], + }, + { + type: "agentMessage", + id: "agent-1", + text: "Hi there", + phase: null, + memoryCitation: null, + }, + ], + }], + }; +} diff --git a/src/__tests__/ReadSessionHistoryTransport.test.ts b/src/__tests__/ReadSessionHistoryTransport.test.ts new file mode 100644 index 00000000..3194910d --- /dev/null +++ b/src/__tests__/ReadSessionHistoryTransport.test.ts @@ -0,0 +1,32 @@ +import * as acp from "@agentclientprotocol/sdk"; +import {describe, expect, it} from "vitest"; +import {LODY_READ_SESSION_HISTORY_METHOD} from "../AcpExtensions"; +import type {CodexAcpServer} from "../CodexAcpServer"; +import {createCodexAcpApp} from "../CodexAcpApp"; + +describe("Lody read-session-history transport", () => { + it("routes the advertised method over an ACP connection", async () => { + const app = createCodexAcpApp({ + name: "read-session-history-test", + createAgent() { + return { + async readSessionHistory(params: Record) { + return {params}; + }, + } as unknown as CodexAcpServer; + }, + }); + + const response = await acp.client({name: "read-session-history-client"}) + .connectWith(app, connection => connection.request( + LODY_READ_SESSION_HISTORY_METHOD, + {sessionId: "session-1"}, + )); + + expect(response).toEqual({ + params: { + sessionId: "session-1", + }, + }); + }); +});