Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)["lody"];
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/CodexAcpApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,14 @@ export class CodexAcpClient {
return this.codexClient.accountRateLimitsRead();
}

async readSessionHistory(sessionId: string): Promise<Thread> {
const response = await this.codexClient.threadRead({
threadId: sessionId,
includeTurns: true,
});
return response.thread;
}

async resumeSession(
request: acp.ResumeSessionRequest,
onSubscribed?: (sessionId?: string) => void,
Expand Down
49 changes: 41 additions & 8 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -379,6 +387,7 @@ export class CodexAcpServer {
},
lody: {
forkAtTurn: {version: 1},
readSessionHistory: LODY_READ_SESSION_HISTORY_CAPABILITY,
},
},
},
Expand Down Expand Up @@ -843,6 +852,27 @@ export class CodexAcpServer {
};
}

async readSessionHistory(
params: LodyReadSessionHistoryRequest,
): Promise<LodyReadSessionHistoryResponse> {
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<LegacyResumeSessionResponse> {
if (this.providerUpdate !== null) {
await this.providerUpdate;
Expand Down Expand Up @@ -1871,19 +1901,22 @@ export class CodexAcpServer {
};
}

private async streamThreadHistory(sessionId: string, thread: Thread): Promise<void> {
private async streamThreadHistory(
sessionId: string,
thread: Thread,
projectionState: HistoryProjectionState = this.getSessionState(sessionId),
): Promise<void> {
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);
}
}
Expand All @@ -1898,7 +1931,7 @@ export class CodexAcpServer {

private async publishThreadHistoryTitle(
session: ACPSessionConnection,
sessionState: SessionState,
sessionState: HistoryProjectionState,
thread: Thread,
): Promise<void> {
const explicitTitle = this.normalizeSessionTitle(thread.name);
Expand Down Expand Up @@ -1937,7 +1970,7 @@ export class CodexAcpServer {
}

private async publishFallbackSessionTitle(
sessionState: SessionState,
sessionState: HistoryProjectionState,
title: string | null,
): Promise<void> {
if (sessionState.sessionTitleSource !== "unset" || !title) return;
Expand Down Expand Up @@ -2014,7 +2047,7 @@ export class CodexAcpServer {

private async createHistoryUpdates(
item: ThreadItem,
sessionState: SessionState,
sessionState: Pick<HistoryProjectionState, "terminalOutputMode">,
turnId: string,
): Promise<UpdateSessionEvent[]> {
switch (item.type) {
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ describe('CodexACPAgent - initialize', () => {
forkAtTurn: {
version: 1,
},
readSessionHistory: {
version: 1,
method: "_lody/session/history/read",
},
},
},
},
Expand Down
121 changes: 121 additions & 0 deletions src/__tests__/CodexACPAgent/read-session-history.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
}],
};
}
32 changes: 32 additions & 0 deletions src/__tests__/ReadSessionHistoryTransport.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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",
},
});
});
});