Skip to content
Closed
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
91 changes: 79 additions & 12 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,60 @@ export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata
export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway";
export const OPENAI_PROVIDER_ID = "openai";
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const UNTITLED_SESSION = "Untitled conversation";
const INJECTED_CONTEXT_TAGS = new Set([
"shared-context",
"task-resources",
"environment_context",
"environment-context",
]);

function stripLeadingInjectedContext(value: string): string {
let remaining = value.trimStart();
while (remaining.length > 0) {
const comment = /^<!--\s*\/?shared-context\s*-->/i.exec(remaining);
if (comment) {
remaining = remaining.slice(comment[0].length).trimStart();
continue;
}

const opening = /^<([a-z_-]+)\b[^>]*>/i.exec(remaining);
const tag = opening?.[1]?.toLowerCase();
if (!opening || !tag || !INJECTED_CONTEXT_TAGS.has(tag)) break;
const closing = new RegExp(`</${tag}\\s*>`, "i").exec(remaining.slice(opening[0].length));
if (!closing) return "";
remaining = remaining
.slice(opening[0].length + closing.index + closing[0].length)
.trimStart();
}
return remaining.replace(/\s+/g, " ").trim();
}

function needsHistoryTitle(thread: Thread): boolean {
return thread.name === null &&
stripLeadingInjectedContext(thread.preview).length === 0;
}

function firstHumanTitle(thread: Thread): string | null {
for (const turn of thread.turns) {
for (const item of turn.items) {
if (item.type !== "userMessage") continue;
const title = item.content
.filter((input): input is Extract<UserInput, {type: "text"}> => input.type === "text")
.map(input => stripLeadingInjectedContext(input.text))
.filter(Boolean)
.join(" ");
if (title) return title;
}
}
return null;
}

function isUserFacingThread(thread: Thread): boolean {
return thread.parentThreadId === null &&
!thread.ephemeral &&
!(typeof thread.source === "object" && "subAgent" in thread.source);
}

/**
* The url-mode variant of the ACP `elicitation/create` request params.
Expand Down Expand Up @@ -1184,32 +1238,45 @@ export class CodexAcpClient {
sourceKinds: sourceKinds,
});

const mapThreadToSession = (thread: Thread) => ({
sessionId: thread.id,
cwd: thread.cwd,
title: (thread.name ?? thread.preview) || null,
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
});
const mapThreadToSession = async (thread: Thread) => {
let title = thread.name ?? stripLeadingInjectedContext(thread.preview);
if (needsHistoryTitle(thread)) {
try {
const history = await this.codexClient.threadReadWithHistory(thread.id);
title = firstHumanTitle(history.thread) ?? UNTITLED_SESSION;
} catch (error) {
logger.error("Failed to derive a session title from thread history", {
threadId: thread.id,
error,
});
title = UNTITLED_SESSION;
}
}
return {
sessionId: thread.id,
cwd: thread.cwd,
title: title || UNTITLED_SESSION,
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
};
};

if (listResponse.data.length === 0) {
const diagnostics = await this.runSessionListDiagnostics();
logger.log("Session list diagnostics", diagnostics);
}

let sessions = listResponse.data.map(mapThreadToSession);
let threads = listResponse.data.filter(isUserFacingThread);
if (requestedCwd) {
const filtered = listResponse.data
.filter(filterByCwd)
.map(mapThreadToSession);
const filtered = threads.filter(filterByCwd);
if (filtered.length > 0 || isAbsolutePathLike(requestedCwd)) {
sessions = filtered;
threads = filtered;
} else {
logger.log("Ignoring non-absolute cwd filter for session/list", {cwd: requestedCwd});
}
}

return {
sessions,
sessions: await Promise.all(threads.map(mapThreadToSession)),
nextCursor: listResponse.nextCursor ?? null,
};
}
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/CodexACPAgent/list-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,105 @@ describe("CodexACPAgent - list sessions", () => {
);
});

it("lists only durable top-level conversations and derives a human fallback title", async () => {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
const codexAppServerClient = fixture.getCodexAppServerClient();
codexAcpClient.authRequired = vi.fn().mockResolvedValue(false);

const root: Thread = {
id: "root",
sessionId: "root",
parentThreadId: null,
threadSource: null,
forkedFromId: null,
preview: "<shared-context>transport context only",
ephemeral: false,
modelProvider: "openai",
model: null,
reasoningEffort: null,
createdAt: 100,
updatedAt: 200,
recencyAt: null,
status: {type: "idle"},
path: null,
cwd: "/repo/project",
cliVersion: "0.0.0",
section: null,
sectionEnteredAt: null,
projectId: null,
historyMode: "legacy",
source: "vscode",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
};
const child = {...root, id: "child", sessionId: "child", parentThreadId: "root"};
const ephemeral = {...root, id: "ephemeral", sessionId: "ephemeral", ephemeral: true};
const guardian: Thread = {
...root,
id: "guardian",
sessionId: "guardian",
source: {subAgent: {other: "guardian"}},
};
const named = {...root, id: "named", sessionId: "named", name: "Saved title"};
const contextOnly = {...root, id: "context-only", sessionId: "context-only"};

codexAppServerClient.threadList = vi.fn().mockResolvedValue({
data: [root, child, ephemeral, guardian, named, contextOnly],
nextCursor: null,
});
codexAppServerClient.threadReadWithHistory = vi.fn().mockImplementation((threadId) => ({
thread: {
...root,
id: threadId,
turns: threadId === "context-only" ? [{
id: "turn-context",
status: "completed",
error: null,
items: [{
type: "userMessage",
id: "message-context",
clientId: null,
content: [{
type: "text",
text: "<environment_context>machine data</environment_context>",
text_elements: [],
}],
}],
}] : [{
id: "turn-1",
status: "completed",
error: null,
items: [{
type: "userMessage",
id: "message-1",
clientId: null,
content: [{
type: "text",
text: "<shared-context>machine data</shared-context>\n<task-resources>files</task-resources>\n<!-- /shared-context -->\nHow do I resume this session?",
text_elements: [],
}],
}],
}],
},
}));

const response = await codexAcpAgent.listSessions({cwd: null, cursor: null});

expect(response.sessions).toEqual([
expect.objectContaining({sessionId: "root", title: "How do I resume this session?"}),
expect.objectContaining({sessionId: "named", title: "Saved title"}),
expect.objectContaining({sessionId: "context-only", title: "Untitled conversation"}),
]);
expect(codexAppServerClient.threadReadWithHistory).toHaveBeenCalledTimes(2);
expect(codexAppServerClient.threadReadWithHistory).toHaveBeenCalledWith("root");
expect(codexAppServerClient.threadReadWithHistory).toHaveBeenCalledWith("context-only");
});

it("includes tracked additional directories for active sessions", async () => {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
Expand Down