Skip to content

Commit fce07c4

Browse files
feat(sessions): create concise titles for saved sessions
Add default-off generated session titles with bounded direct-user transcript input, durable session metadata, gateway and Control UI projection, and regression coverage for auth routing, stale writes, reset/rollover cleanup, and duplicate in-flight generation.
1 parent 11eeba9 commit fce07c4

37 files changed

Lines changed: 1597 additions & 24 deletions
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
acdf418738f79d29f58cb6cfe5b7ab2d353cbcce2252861a4f527f85ea0c54af config-baseline.json
2-
4c98c716bc78e65c274ec374757357c1dcc9b5ec75c9e00ea4c20851531b7d1a config-baseline.core.json
3-
c68853362689981ac1cc1e55b9061286c2002104ff1c10bc44ee99a6080e169e config-baseline.channel.json
4-
859aa272b0dad53b7080c6fefcf775347ae79a1998ec39dd18b732c90d9df90c config-baseline.plugin.json
1+
eac17c8f19771fb909121a879b37919330673b3392c7193b1bdf0e143a824ab5 config-baseline.json
2+
444e826ce723865a67d87d25bd749b3112546a245ab139a9a8352b63362e19c9 config-baseline.core.json
3+
d27ac1e30c6f3ef7292f33ad9737d4372dd140675293cbddc38364e476f06410 config-baseline.channel.json
4+
e228f29f17758763a098b0ccd10c6db7fc9df84840437ed3f88a88cf945b8078 config-baseline.plugin.json

src/auto-reply/reply/command-session-metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { HandleCommandsParams } from "./commands-types.js";
55
export type CommandSessionMetadataChange = {
66
sessionKey: string;
77
agentId?: string;
8-
reason: "command-metadata";
8+
reason: "command-metadata" | "session-title";
99
};
1010

1111
const commandSessionMetadataChanges = new WeakMap<object, CommandSessionMetadataChange[]>();

src/auto-reply/reply/conversation-label-generator.test.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ vi.mock("../../agents/simple-completion-transport.js", () => ({
3535
}));
3636

3737
vi.mock("../../plugins/runtime/runtime-model-auth.runtime.js", () => ({
38-
getRuntimeAuthForModel,
38+
getRuntimeAuthForModelWithSelection: getRuntimeAuthForModel,
3939
}));
4040

4141
import { generateConversationLabel } from "./conversation-label-generator.js";
@@ -76,13 +76,14 @@ describe("generateConversationLabel", () => {
7676
vi.useRealTimers();
7777
});
7878

79-
it("uses routed agentDir for model and auth resolution", async () => {
79+
it("uses routed agentDir for model resolution and workspaceDir for auth hooks", async () => {
8080
await generateConversationLabel({
8181
userMessage: "Need help with invoices",
8282
prompt: "prompt",
8383
cfg: {},
8484
agentId: "billing",
8585
agentDir: "/tmp/agents/billing/agent",
86+
workspaceDir: "/tmp/workspaces/billing",
8687
});
8788

8889
expect(resolveDefaultModelForAgent).toHaveBeenCalledWith({
@@ -94,18 +95,98 @@ describe("generateConversationLabel", () => {
9495
"gpt-test",
9596
"/tmp/agents/billing/agent",
9697
{},
98+
{ workspaceDir: "/tmp/workspaces/billing" },
9799
);
98100
expect(getRuntimeAuthForModel).toHaveBeenCalledWith({
99101
model: { provider: "openai" },
100102
cfg: {},
101-
workspaceDir: "/tmp/agents/billing/agent",
103+
agentDir: "/tmp/agents/billing/agent",
104+
workspaceDir: "/tmp/workspaces/billing",
102105
});
103106
expect(prepareModelForSimpleCompletion).toHaveBeenCalledWith({
104107
model: { provider: "openai" },
105108
cfg: {},
106109
});
107110
});
108111

112+
it("passes session auth profile to runtime auth lookup", async () => {
113+
await generateConversationLabel({
114+
userMessage: "Need help with invoices",
115+
prompt: "prompt",
116+
cfg: {},
117+
authProfileId: "openai:work",
118+
authProfileIdSource: "user",
119+
});
120+
121+
expect(resolveModelAsync).toHaveBeenCalledWith(
122+
"openai",
123+
"gpt-test",
124+
undefined,
125+
{},
126+
{
127+
authProfileId: "openai:work",
128+
},
129+
);
130+
expect(getRuntimeAuthForModel).toHaveBeenCalledWith({
131+
model: { provider: "openai" },
132+
cfg: {},
133+
agentDir: undefined,
134+
workspaceDir: undefined,
135+
profileId: "openai:work",
136+
lockedProfile: true,
137+
});
138+
});
139+
140+
it("passes auto-selected session auth as preferred runtime profile", async () => {
141+
await generateConversationLabel({
142+
userMessage: "Need help with invoices",
143+
prompt: "prompt",
144+
cfg: {},
145+
authProfileId: "openai:last-good",
146+
authProfileIdSource: "auto",
147+
});
148+
149+
expect(resolveModelAsync).toHaveBeenCalledWith(
150+
"openai",
151+
"gpt-test",
152+
undefined,
153+
{},
154+
{
155+
preferredProfile: "openai:last-good",
156+
},
157+
);
158+
expect(getRuntimeAuthForModel).toHaveBeenCalledWith({
159+
model: { provider: "openai" },
160+
cfg: {},
161+
agentDir: undefined,
162+
workspaceDir: undefined,
163+
preferredProfile: "openai:last-good",
164+
});
165+
});
166+
167+
it("ignores incomplete model overrides instead of pairing mismatched provider and model", async () => {
168+
await generateConversationLabel({
169+
userMessage: "Need help with invoices",
170+
prompt: "prompt",
171+
cfg: {},
172+
modelProvider: "anthropic",
173+
});
174+
175+
expect(resolveModelAsync).toHaveBeenCalledWith("openai", "gpt-test", undefined, {});
176+
});
177+
178+
it("uses complete model override pairs", async () => {
179+
await generateConversationLabel({
180+
userMessage: "Need help with invoices",
181+
prompt: "prompt",
182+
cfg: {},
183+
modelProvider: "anthropic",
184+
modelId: "claude-sonnet-4-6",
185+
});
186+
187+
expect(resolveModelAsync).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-6", undefined, {});
188+
});
189+
109190
it("passes the label prompt as systemPrompt and the user text as message content", async () => {
110191
vi.useFakeTimers();
111192
vi.setSystemTime(1_710_000_000_000);

src/auto-reply/reply/conversation-label-generator.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
88
import { logVerbose } from "../../globals.js";
99
import { completeSimple } from "../../llm/stream.js";
1010
import type { TextContent } from "../../llm/types.js";
11-
import { getRuntimeAuthForModel } from "../../plugins/runtime/runtime-model-auth.runtime.js";
11+
import { getRuntimeAuthForModelWithSelection } from "../../plugins/runtime/runtime-model-auth.runtime.js";
1212

1313
const DEFAULT_MAX_LABEL_LENGTH = 128;
1414
const TIMEOUT_MS = 15_000;
@@ -20,7 +20,14 @@ export type ConversationLabelParams = {
2020
cfg: OpenClawConfig;
2121
agentId?: string;
2222
agentDir?: string;
23+
workspaceDir?: string;
2324
maxLength?: number;
25+
/** Session-scoped model provider override (e.g. from /model). */
26+
modelProvider?: string;
27+
/** Session-scoped model ID override (e.g. from /model). */
28+
modelId?: string;
29+
authProfileId?: string;
30+
authProfileIdSource?: "auto" | "user";
2431
};
2532

2633
function isTextContentBlock(block: { type: string }): block is TextContent {
@@ -45,29 +52,63 @@ function extractSimpleCompletionError(result: {
4552
export async function generateConversationLabel(
4653
params: ConversationLabelParams,
4754
): Promise<string | null> {
48-
const { userMessage, prompt, cfg, agentId, agentDir } = params;
55+
const {
56+
userMessage,
57+
prompt,
58+
cfg,
59+
agentId,
60+
agentDir,
61+
workspaceDir,
62+
modelProvider,
63+
modelId,
64+
authProfileId,
65+
authProfileIdSource,
66+
} = params;
4967
const maxLength =
5068
typeof params.maxLength === "number" &&
5169
Number.isFinite(params.maxLength) &&
5270
params.maxLength > 0
5371
? Math.floor(params.maxLength)
5472
: DEFAULT_MAX_LABEL_LENGTH;
55-
const modelRef = resolveDefaultModelForAgent({ cfg, agentId });
56-
const resolved = await resolveModelAsync(modelRef.provider, modelRef.model, agentDir, cfg);
73+
const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId });
74+
const overrideProvider = modelProvider && modelId ? modelProvider : undefined;
75+
const overrideModel = modelProvider && modelId ? modelId : undefined;
76+
const resolvedProvider = overrideProvider ?? defaultModelRef.provider;
77+
const resolvedModel = overrideModel ?? defaultModelRef.model;
78+
const modelResolutionOptions = {
79+
...(workspaceDir ? { workspaceDir } : {}),
80+
...(authProfileId && authProfileIdSource === "user" ? { authProfileId } : {}),
81+
...(authProfileId && authProfileIdSource !== "user" ? { preferredProfile: authProfileId } : {}),
82+
};
83+
const resolved =
84+
Object.keys(modelResolutionOptions).length > 0
85+
? await resolveModelAsync(
86+
resolvedProvider,
87+
resolvedModel,
88+
agentDir,
89+
cfg,
90+
modelResolutionOptions,
91+
)
92+
: await resolveModelAsync(resolvedProvider, resolvedModel, agentDir, cfg);
5793
if (!resolved.model) {
5894
logVerbose(
59-
`conversation-label-generator: failed to resolve model ${modelRef.provider}/${modelRef.model}`,
95+
`conversation-label-generator: failed to resolve model ${resolvedProvider}/${resolvedModel}`,
6096
);
6197
return null;
6298
}
6399
const completionModel = prepareModelForSimpleCompletion({ model: resolved.model, cfg });
64100

65-
const runtimeAuth = await getRuntimeAuthForModel({
101+
const runtimeAuth = await getRuntimeAuthForModelWithSelection({
66102
model: completionModel,
67103
cfg,
68-
workspaceDir: agentDir,
104+
agentDir,
105+
workspaceDir,
106+
...(authProfileId && authProfileIdSource === "user"
107+
? { profileId: authProfileId, lockedProfile: true }
108+
: {}),
109+
...(authProfileId && authProfileIdSource !== "user" ? { preferredProfile: authProfileId } : {}),
69110
});
70-
const apiKey = requireApiKey(runtimeAuth, modelRef.provider);
111+
const apiKey = requireApiKey(runtimeAuth, resolvedProvider);
71112
const runtimeModel = applyPreparedRuntimeAuthToModel(completionModel, runtimeAuth);
72113

73114
const controller = new AbortController();

src/auto-reply/reply/get-reply-run.media-only.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ vi.mock("../../config/sessions/paths.js", () => ({
3939
const storeRuntimeLoads = vi.hoisted(() => vi.fn());
4040
const updateSessionStore = vi.hoisted(() => vi.fn());
4141
const updateAmbientTranscriptWatermarkMock = vi.hoisted(() => vi.fn().mockResolvedValue(null));
42+
const maybeGenerateSessionTitle = vi.hoisted(() => vi.fn());
4243

4344
vi.mock("../../config/sessions/ambient-transcript-watermark.js", () => ({
4445
updateAmbientTranscriptWatermark: updateAmbientTranscriptWatermarkMock,
@@ -132,6 +133,10 @@ vi.mock("./session-system-events.js", () => ({
132133
drainFormattedSystemEvents: vi.fn().mockResolvedValue(undefined),
133134
}));
134135

136+
vi.mock("./session-title-generator.js", () => ({
137+
maybeGenerateSessionTitle,
138+
}));
139+
135140
vi.mock("./typing-mode.js", () => ({
136141
resolveTypingMode: vi.fn().mockReturnValue("off"),
137142
}));
@@ -344,6 +349,120 @@ describe("runPreparedReply media-only handling", () => {
344349
});
345350
});
346351

352+
it("forwards generated title metadata changes after a successful reply", async () => {
353+
const onSessionMetadataChanges = vi.fn();
354+
vi.mocked(maybeGenerateSessionTitle).mockImplementationOnce((params: unknown) => {
355+
(params as { onTitleGenerated?: (change: unknown) => void }).onTitleGenerated?.({
356+
sessionKey: "agent:main:title-session",
357+
agentId: "main",
358+
reason: "session-title",
359+
});
360+
});
361+
362+
await runPreparedReply(
363+
baseParams({
364+
cfg: {
365+
session: {},
366+
channels: {},
367+
agents: { defaults: {} },
368+
sessionTitle: { enabled: true },
369+
},
370+
agentId: "main",
371+
sessionKey: "agent:main:title-session",
372+
sessionEntry: { sessionId: "title-session" } as SessionEntry,
373+
storePath: "/tmp/sessions.json",
374+
opts: { onSessionMetadataChanges },
375+
}),
376+
);
377+
378+
await vi.waitFor(() =>
379+
expect(onSessionMetadataChanges).toHaveBeenCalledWith([
380+
{
381+
sessionKey: "agent:main:title-session",
382+
agentId: "main",
383+
reason: "session-title",
384+
},
385+
]),
386+
);
387+
});
388+
389+
it("triggers generated title handoff after a successful block-only reply", async () => {
390+
vi.mocked(runReplyAgent).mockResolvedValueOnce(undefined);
391+
392+
await runPreparedReply(
393+
baseParams({
394+
cfg: {
395+
session: {},
396+
channels: {},
397+
agents: { defaults: {} },
398+
sessionTitle: { enabled: true },
399+
},
400+
agentId: "main",
401+
sessionKey: "agent:main:block-title-session",
402+
sessionEntry: { sessionId: "block-title-session" } as SessionEntry,
403+
storePath: "/tmp/sessions.json",
404+
blockStreamingEnabled: true,
405+
}),
406+
);
407+
408+
await vi.waitFor(() => expect(maybeGenerateSessionTitle).toHaveBeenCalledOnce());
409+
expect(maybeGenerateSessionTitle).toHaveBeenCalledWith(
410+
expect.objectContaining({
411+
sessionKey: "agent:main:block-title-session",
412+
}),
413+
);
414+
});
415+
416+
it("passes post-run session auth profile to generated title handoff", async () => {
417+
const { resolveSessionAuthProfileOverride } =
418+
await import("../../agents/auth-profiles/session-override.js");
419+
const sessionEntry: SessionEntry = {
420+
sessionId: "title-session",
421+
sessionFile: "/tmp/title-session.jsonl",
422+
modelProvider: "anthropic",
423+
model: "claude-opus-4-1",
424+
authProfileOverride: "anthropic:default",
425+
authProfileOverrideSource: "user",
426+
updatedAt: 1,
427+
};
428+
vi.mocked(resolveSessionAuthProfileOverride).mockImplementation(async (params) => {
429+
return params.sessionEntry?.authProfileOverride;
430+
});
431+
vi.mocked(runReplyAgent).mockImplementationOnce(async (params) => {
432+
if (params.sessionEntry) {
433+
params.sessionEntry.modelProvider = "zai";
434+
params.sessionEntry.model = "glm-4.5";
435+
params.sessionEntry.authProfileOverride = "zai:work";
436+
params.sessionEntry.authProfileOverrideSource = "auto";
437+
}
438+
return { text: "ok" };
439+
});
440+
441+
await runPreparedReply(
442+
baseParams({
443+
cfg: {
444+
session: {},
445+
channels: {},
446+
agents: { defaults: {} },
447+
sessionTitle: { enabled: true },
448+
},
449+
agentId: "main",
450+
sessionKey: "agent:main:title-session",
451+
sessionEntry,
452+
storePath: "/tmp/sessions.json",
453+
}),
454+
);
455+
456+
await vi.waitFor(() => expect(maybeGenerateSessionTitle).toHaveBeenCalledOnce());
457+
expect(maybeGenerateSessionTitle).toHaveBeenCalledWith(
458+
expect.objectContaining({
459+
sessionEntry,
460+
authProfileId: "zai:work",
461+
authProfileIdSource: "auto",
462+
}),
463+
);
464+
});
465+
347466
it("propagates non-visible assistant silence for group runs", async () => {
348467
await runPreparedReply(baseParams());
349468

0 commit comments

Comments
 (0)