diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c7eba075..bcda65d3 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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; }; /** @@ -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); @@ -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 — diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 12a7d23e..f61fb837 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -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"); + }); + }); });