Skip to content
Open
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
22 changes: 22 additions & 0 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,13 @@ export type NewSessionMeta = {
emitRawSDKMessages?: boolean | SDKMessageFilter[];
};
additionalRoots?: string[];
/**
* Harness-neutral title for a newly created session, forwarded to the SDK as
* `Options.title`. Only applied when creating or forking a session — the SDK
* does not honor `title` on resume. `_meta.claudeCode.options.title` takes
* precedence when both are given.
*/
sessionTitle?: string;
};

/**
Expand Down Expand Up @@ -5193,6 +5200,20 @@ export class ClaudeAcpAgent {
const sessionMeta = params._meta as NewSessionMeta | undefined;
const userProvidedOptions = sessionMeta?.claudeCode?.options;

// Harness-neutral session title, mapped onto the SDK's own `title` option.
// Only a genuinely new session (creation or fork) gets one: the SDK ignores
// `title` when resuming, so gating here makes that explicit. A non-string
// is ignored rather than rejected, matching how the other `_meta` reads
// here treat malformed input.
const isNewSession = creationOpts.resume === undefined || creationOpts.forkSession === true;
const sessionTitle =
isNewSession && typeof sessionMeta?.sessionTitle === "string"
? sanitizeTitle(sessionMeta.sessionTitle)
: "";
// `_meta.claudeCode.options.title` is the SDK-shaped spelling and already
// reaches the SDK today, so it wins when a caller supplies both.
const title = userProvidedOptions?.title ?? sessionTitle;

// Configure thinking behavior from environment variable
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);

Expand Down Expand Up @@ -5255,6 +5276,7 @@ export class ClaudeAcpAgent {
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
...(title && { title }),
// CLAUDE_MODEL_CONFIG env var is a fallback for model
// configuration (e.g. Bedrock model ID overrides). When the caller
// provides settings via _meta, we intentionally ignore the env var —
Expand Down
73 changes: 73 additions & 0 deletions src/tests/create-session-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,4 +855,77 @@ describe("createSession options merging", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded"));
});
});

describe("_meta.sessionTitle", () => {
function createSessionWith(_meta: unknown, creationOpts?: object) {
return (
agent as unknown as {
createSession: (params: object, opts?: object) => Promise<{ sessionId: string }>;
}
).createSession({ cwd: process.cwd(), mcpServers: [], _meta }, creationOpts);
}

it("forwards a client-supplied session title to the SDK, sanitized", async () => {
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { sessionTitle: " Fix\nthe login bug " },
});

expect(capturedOptions!.title).toBe("Fix the login bug");
});

it("truncates an over-long session title to the adapter's title limit", async () => {
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { sessionTitle: "x".repeat(300) },
});

expect(capturedOptions!.title).toBe("x".repeat(255) + "…");
});

it.each([
["absent", {}],
["null", { sessionTitle: null }],
["a number", { sessionTitle: 42 }],
["an object", { sessionTitle: { text: "nope" } }],
["blank", { sessionTitle: " \n " }],
])("omits the title when sessionTitle is %s", async (_label, _meta) => {
await agent.newSession({ cwd: process.cwd(), mcpServers: [], _meta });

expect(capturedOptions!.title).toBeUndefined();
});

it("lets the SDK-shaped claudeCode.options.title win over sessionTitle", async () => {
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: {
sessionTitle: "harness-neutral title",
claudeCode: { options: { title: "sdk-shaped title" } },
},
});

expect(capturedOptions!.title).toBe("sdk-shaped title");
});

it("omits the title when resuming an existing session", async () => {
await createSessionWith(
{ sessionTitle: "Resumed title" },
{ resume: "session-title-resume" },
);

expect(capturedOptions!.title).toBeUndefined();
});

it("forwards the title when forking, which creates a new session", async () => {
await createSessionWith(
{ sessionTitle: "Forked title" },
{ resume: "session-title-fork", forkSession: true },
);

expect(capturedOptions!.title).toBe("Forked title");
});
});
});