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
3 changes: 3 additions & 0 deletions src/agentMode/backends/claude/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
// The Claude SDK adapter re-reads the composed system prompt per
// `newSession()`, so a new chat picks up prompt changes without a restart.
restartOnSystemPromptChange: false,
// The Claude SDK exposes no session-title API, so the session derives the tab
// title client-side from the user's first message.
summarizesSessionTitle: false,
wire: claudeWire,
showModelDescriptions: true,

Expand Down
3 changes: 3 additions & 0 deletions src/agentMode/backends/codex/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export const CodexBackendDescriptor: BackendDescriptor = {
restartOnManagedSkillsChange: false,
restartOnProviderConfigChange: false,
restartOnSystemPromptChange: true,
// codex names a session after the raw first prompt (which leaks the injected
// context envelope), so the session derives the tab title client-side instead.
summarizesSessionTitle: false,
Comment thread
logancyang marked this conversation as resolved.
wire: codexWire,
showModelDescriptions: true,

Expand Down
2 changes: 2 additions & 0 deletions src/agentMode/backends/opencode/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
restartOnManagedSkillsChange: true,
restartOnProviderConfigChange: true,
restartOnSystemPromptChange: true,
// opencode runs a title-summarizer agent and returns clean session titles.
summarizesSessionTitle: true,
wire: opencodeWire,

getEnabledModelEntries(settings: CopilotSettings): EnabledModelEntry[] {
Expand Down
101 changes: 101 additions & 0 deletions src/agentMode/session/AgentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ function makeMockBackend(): MockBackend {
};
}

/** Descriptor stub for a backend that summarizes its own titles (opencode). */
function summarizingDescriptor(): BackendDescriptor {
return { summarizesSessionTitle: true } as unknown as BackendDescriptor;
}

/** Descriptor stub for a backend that does NOT summarize (codex, Claude Code). */
function nonSummarizingDescriptor(): BackendDescriptor {
return { summarizesSessionTitle: false } as unknown as BackendDescriptor;
}

describe("buildPromptBlocks", () => {
// eslint-disable-next-line obsidianmd/no-tfile-tfolder-cast -- test fixture; not a real TFile
const makeFile = (path: string) => ({ path }) as unknown as TFile;
Expand Down Expand Up @@ -1755,6 +1765,97 @@ describe("AgentSession title poll after turn", () => {
});
});

describe("AgentSession client-derived title (non-summarizing backends)", () => {
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

function makeSession(backendId: "codex" | "claude" | "opencode", summarizes: boolean) {
return new AgentSession({
backend: makeMockBackend().asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId,
getDescriptor: summarizes ? summarizingDescriptor : nonSummarizingDescriptor,
});
}

it("derives the tab label from the first user message for codex", () => {
const session = makeSession("codex", false);
session.sendPrompt("Summarize my meeting notes");
expect(session.getLabel()).toBe("Summarize my meeting notes");
expect(session.getLabelSource()).toBe("agent");
});

it("derives the tab label from the first user message for Claude Code", () => {
const session = makeSession("claude", false);
session.sendPrompt("Refactor the auth module");
expect(session.getLabel()).toBe("Refactor the auth module");
});

it("strips wikilink brackets when deriving the title", () => {
const session = makeSession("codex", false);
session.sendPrompt("Review [[Project Plan]] please");
expect(session.getLabel()).toBe("Review Project Plan please");
});

it("keeps the first message's derived title across later turns", async () => {
const session = makeSession("codex", false);
await session.sendPrompt("First prompt").turn;
expect(session.getLabel()).toBe("First prompt");
await session.sendPrompt("A different second prompt").turn;
expect(session.getLabel()).toBe("First prompt");
});

it("does not override a user rename with a derived title", () => {
const session = makeSession("codex", false);
session.setLabel("My rename");
session.sendPrompt("Some prompt that would otherwise become the title");
expect(session.getLabel()).toBe("My rename");
expect(session.getLabelSource()).toBe("user");
});

it("does not derive a label for a summarizing backend (opencode)", () => {
const session = makeSession("opencode", true);
session.sendPrompt("This should not become the tab title");
expect(session.getLabel()).toBeNull();
});

it("ignores a backend-pushed session_info_update title for non-summarizing backends", () => {
const mock = makeMockBackend();
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "codex",
getDescriptor: nonSummarizingDescriptor,
});
session.sendPrompt("Original user prompt");
mock.emit({
sessionId: "acp-1",
update: { sessionUpdate: "session_info_update", title: "<copilot-context> leaked title" },
});
expect(session.getLabel()).toBe("Original user prompt");
});

it("does not poll listSessions after a turn for non-summarizing backends", async () => {
const mock = makeMockBackend();
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "codex",
cwd: "/vault",
getDescriptor: nonSummarizingDescriptor,
});
await session.sendPrompt("hello").turn;
await flushMicrotasks();
expect(mock.listSessions).not.toHaveBeenCalled();
});
});

describe("AgentSession plan proposal lifecycle", () => {
it("does not resurrect the plan card when a late tool_call_update arrives for a finalized proposal", async () => {
const mock = makeMockBackend();
Expand Down
26 changes: 25 additions & 1 deletion src/agentMode/session/AgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { err2String, formatDateTime } from "@/utils";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveMcpServers } from "@/agentMode/session/mcpResolver";
import { deriveChatTitleFromMessages } from "@/agentMode/session/chatHistoryMerge";
import { getSettings } from "@/settings/model";
import { ContextProcessor } from "@/contextProcessor";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
Expand Down Expand Up @@ -684,6 +685,17 @@ export class AgentSession {
else this.applyAgentLabel(label);
}

/**
* Whether this backend produces its own clean session titles (opencode). When
* false (codex, Claude Code) the session derives the tab label client-side and
* ignores any backend-provided title. Defaults to `true` when no descriptor is
* wired (legacy/test construction), preserving the prior trust-the-backend
* behavior.
*/
private backendSummarizesTitle(): boolean {
return this.getDescriptor?.()?.summarizesSessionTitle ?? true;
}

private applyAgentLabel(label: string | null | undefined): void {
if (this.labelSource === "user") return;
const next = label?.trim() ? label.trim() : null;
Expand Down Expand Up @@ -752,6 +764,13 @@ export class AgentSession {
this.currentMessageIds = new Set();
this.notifyMessages();

// Backends without a title summarizer (codex, Claude Code) have no usable
// backend-provided title, so derive the tab label from the first user
// message. Recorded agent-sourced (not "user"), so a later Rename still wins.
if (this.label === null && !this.backendSummarizesTitle()) {
this.applyAgentLabel(deriveChatTitleFromMessages(this.store.getDisplayMessages()));
}

this.abortController = new AbortController();
// Clear any prior terminal error before the new turn starts so the
// derived status reflects the fresh `"running"` state. Both flips
Expand Down Expand Up @@ -1146,7 +1165,9 @@ export class AgentSession {

// Session-scoped updates aren't tied to a turn placeholder.
if (update.sessionUpdate === "session_info_update") {
this.applyAgentLabel(update.title);
// Only trust a pushed title from a backend that actually summarizes
// (opencode). codex would push its raw-prompt-derived title here.
if (this.backendSummarizesTitle()) this.applyAgentLabel(update.title);
return;
}
if (update.sessionUpdate === "state_changed") {
Expand Down Expand Up @@ -1372,6 +1393,9 @@ export class AgentSession {
*/
private async pollSessionTitle(): Promise<void> {
if (this.labelSource === "user") return;
// Only backends that summarize return a clean title; for the rest the tab
// label is derived client-side from the first user message (see sendPrompt).
if (!this.backendSummarizesTitle()) return;
try {
const resp = await this.backend.listSessions(this.cwd ? { cwd: this.cwd } : {});
const entry = resp.sessions.find((s) => s.sessionId === this.backendSessionId);
Expand Down
26 changes: 26 additions & 0 deletions src/agentMode/session/AgentSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ function buildDescriptor(): BackendDescriptor {
return {
id: "opencode",
displayName: "opencode",
// opencode runs a title summarizer, so native title discovery trusts it.
summarizesSessionTitle: true,
// Default to installed: a manager that owns a running backend is, in
// production, always operating on an installed one. Tests that exercise
// the uninstalled path override this per-case.
Expand Down Expand Up @@ -1313,6 +1315,8 @@ describe("AgentSessionManager chat history aggregation", () => {
/** When set, the preloader exposes a warm opencode probe proc with this listSessions. */
warmListSessions?: jest.Mock;
probeSessionId?: string;
/** Defaults to true; set false to model a non-summarizing backend (codex). */
summarizesSessionTitle?: boolean;
}) {
const frontmatterByPath = opts?.files ?? {};
const hiddenByPath = opts?.hiddenFiles ?? {};
Expand Down Expand Up @@ -1356,6 +1360,7 @@ describe("AgentSessionManager chat history aggregation", () => {
const index = new AgentSessionIndex(makeIndexStorage(), "plugins/copilot/index.json");
const descriptor = {
...buildDescriptor(),
summarizesSessionTitle: opts?.summarizesSessionTitle ?? true,
getProbeSessionId: jest.fn(() => opts?.probeSessionId),
} as unknown as BackendDescriptor;
if (opts?.listSessions) {
Expand Down Expand Up @@ -1605,4 +1610,25 @@ describe("AgentSessionManager chat history aggregation", () => {
expect(native[0]?.title).toBe("Real chat");
expect(native[0]?.lastAccessedAt.getTime()).toBe(7_000);
});

it("skips native title discovery for non-summarizing backends (codex)", async () => {
// codex names a session after the raw first prompt, so its listSessions
// title can leak the injected context envelope. The sweep must not trust it.
const listSessions = jest.fn(async () => ({
sessions: [
{
sessionId: "ctx-leak",
cwd: "/vault",
title: "<copilot-context> The user attached the following vault items",
updatedAt: null,
},
],
}));
const { manager } = buildHistoryHarness({ listSessions, summarizesSessionTitle: false });
await manager.createSession("opencode");

const items = await manager.getChatHistoryItems();
expect(listSessions).not.toHaveBeenCalled();
expect(items.filter((i) => i.id.startsWith("copilot-agent-session://"))).toHaveLength(0);
});
});
11 changes: 8 additions & 3 deletions src/agentMode/session/AgentSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ export class AgentSessionManager {
): Promise<void> {
const index = this.opts.sessionIndex;
if (!index) return;
const descriptor = this.opts.resolveDescriptor(backendId);
// Only backends that summarize their own titles contribute trustworthy
// titles to native discovery. For the rest (codex, Claude Code) the agent's
// title is the raw first prompt (which leaks the injected context envelope),
// and the sweep has no transcript to derive a clean one — those sessions are
// indexed via flushIndexTouch with a client-derived title instead.
if (!descriptor?.summarizesSessionTitle) return;
let sessions;
try {
({ sessions } = await proc.listSessions({ cwd: vaultBasePath }));
Expand All @@ -392,9 +399,7 @@ export class AgentSessionManager {
}
return;
}
const probeSessionId = this.opts
.resolveDescriptor(backendId)
?.getProbeSessionId?.(getSettings());
const probeSessionId = descriptor.getProbeSessionId?.(getSettings());
const now = Date.now();
const discovered = [];
for (const s of sessions) {
Expand Down
15 changes: 15 additions & 0 deletions src/agentMode/session/descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ export interface BackendDescriptor {
*/
readonly restartOnSystemPromptChange: boolean;

/**
* When true, this backend runs its own title-summarizer agent and returns a
* clean conversation title via `session/list` / a pushed `session_info_update`
* (opencode). The session trusts those titles.
*
* When false (codex, Claude Code), the backend has no usable title source:
* codex names a session after the raw first prompt, leaking the injected
* `<copilot-context>` envelope, and the Claude SDK exposes no title API. For
* these the session derives the tab title client-side from the user's first
* visible message and ignores any backend-provided title.
*
* Required (not optional) so a new backend must make an explicit decision.
*/
readonly summarizesSessionTitle: boolean;

/** Sync read of install/setup state from settings + last-known disk reconcile. */
getInstallState(settings: CopilotSettings): InstallState;

Expand Down
Loading