Skip to content

Commit 7c38a59

Browse files
committed
feat: generate session titles via SiliconFlow asynchronously
Run title generation in the background and update live status when available, while falling back to a unified Opencode running header without blocking message handling.
1 parent 702141c commit 7c38a59

File tree

9 files changed

+158
-48
lines changed

9 files changed

+158
-48
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ode",
3-
"version": "0.0.79",
3+
"version": "0.0.80",
44
"description": "Coding anywhere with your coding agents connected",
55
"module": "packages/core/index.ts",
66
"type": "module",

packages/agents/test/claude-stream-status.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ describe("claude stream status parsing", () => {
378378
expect(text).not.toContain(longResponse);
379379
});
380380

381-
it("falls back to provider header when title is unavailable", () => {
381+
it("falls back to opencode header when title is unavailable", () => {
382382
const now = Date.now();
383383
const state = buildSessionMessageState([
384384
rawEvent(now, {
@@ -403,6 +403,6 @@ describe("claude stream status parsing", () => {
403403
"minimum"
404404
);
405405

406-
expect(text).toContain("*Claude Code Working...*");
406+
expect(text).toContain("*Opencode is running...*");
407407
});
408408
});

packages/agents/test/gemini-stream-status.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("gemini stream status parsing", () => {
5454
expect(state.phaseStatus).toBe("Drafting response");
5555
});
5656

57-
it("uses Gemini fallback header when title is missing", () => {
57+
it("uses opencode fallback header when title is missing", () => {
5858
const now = Date.now();
5959
const state = buildSessionMessageState([
6060
rawEvent(now, {
@@ -77,6 +77,6 @@ describe("gemini stream status parsing", () => {
7777
"medium"
7878
);
7979

80-
expect(text).toContain("*Gemini Working...*");
80+
expect(text).toContain("*Opencode is running...*");
8181
});
8282
});

packages/core/runtime/open-request.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/config/local/sessions";
1111
import { runTrackedRequest } from "@/core/runtime/request-runner";
1212
import { buildStatusMessageForAgent } from "@/core/runtime/status-message";
13+
import { maybeGenerateSessionTitle } from "@/core/runtime/session-title";
1314
import { CoreStateMachine } from "@/core/state-machine";
1415
import type { OpenCodeOptions } from "@/agents";
1516
import type { AgentAdapter, CoreMessageContext, IMAdapter } from "@/core/types";
@@ -54,12 +55,10 @@ export async function runOpenRequest(params: {
5455
publishFinalText,
5556
} = params;
5657

57-
const providerLabel = deps.agent.getDisplayNameForSession(sessionId);
58-
5958
const statusTs = await deps.im.sendMessage(
6059
context.channelId,
6160
context.replyThreadId,
62-
`${providerLabel} is working...`,
61+
"Opencode is running...",
6362
false
6463
);
6564

@@ -79,6 +78,14 @@ export async function runOpenRequest(params: {
7978
session.activeRequest = request;
8079
saveSession(session);
8180

81+
const statusMessageKey = getStatusMessageKey(request);
82+
void maybeGenerateSessionTitle({
83+
prompt: message,
84+
stateKey: statusMessageKey,
85+
liveParsedState,
86+
startedAt: request.startedAt,
87+
});
88+
8289
const progressIntervalMs = resolveMessageUpdateIntervalMs();
8390
let lastHeartbeat = Date.now();
8491
const result = await runTrackedRequest({
@@ -109,7 +116,7 @@ export async function runOpenRequest(params: {
109116
agent: deps.agent,
110117
request,
111118
workingPath: cwd,
112-
state: liveParsedState.get(getStatusMessageKey(request)),
119+
state: liveParsedState.get(statusMessageKey),
113120
statusMessageFormat: resolveStatusMessageFormat(),
114121
});
115122
if (!request.statusFrozen) {

packages/core/runtime/selection-reply.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { resolveStatusMessageFormat } from "@/config";
1111
import { buildMessageOptions } from "@/core/runtime/message-options";
1212
import { runTrackedRequest } from "@/core/runtime/request-runner";
1313
import { buildStatusMessageForAgent } from "@/core/runtime/status-message";
14+
import { maybeGenerateSessionTitle } from "@/core/runtime/session-title";
1415
import { CoreStateMachine } from "@/core/state-machine";
1516
import type { OpenCodeOptions } from "@/agents";
1617
import type { AgentAdapter, IMAdapter } from "@/core/types";
@@ -69,15 +70,22 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams):
6970
markMessageProcessed(channelId, threadId, messageTs);
7071

7172
const providerId = deps.agent.getProviderForSession(sessionId);
72-
const providerLabel = deps.agent.getDisplayNameForSession(sessionId);
7373

74-
const statusTs = await deps.im.sendMessage(channelId, threadId, `${providerLabel} is working...`, false);
74+
const statusTs = await deps.im.sendMessage(channelId, threadId, "Opencode is running...", false);
7575
if (!statusTs) {
7676
log.error("Failed to send status message for button selection");
7777
return;
7878
}
7979

8080
const request = createActiveRequest(sessionId, channelId, threadId, threadId, statusTs, selection);
81+
const statusMessageKey = getStatusMessageKey(request);
82+
83+
void maybeGenerateSessionTitle({
84+
prompt: selection,
85+
stateKey: statusMessageKey,
86+
liveParsedState: state.liveParsedState,
87+
startedAt: request.startedAt,
88+
});
8189

8290
const session = loadSession(channelId, threadId);
8391
if (session) {
@@ -125,7 +133,7 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams):
125133
agent: deps.agent,
126134
request,
127135
workingPath: cwd,
128-
state: state.liveParsedState.get(getStatusMessageKey(request)),
136+
state: state.liveParsedState.get(statusMessageKey),
129137
statusMessageFormat: resolveStatusMessageFormat(),
130138
});
131139
await deps.im.updateMessage(channelId, statusTs, statusText, false);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { SessionMessageState } from "@/utils";
2+
import { log } from "@/utils";
3+
4+
const SILICONFLOW_API_URL = "https://api.siliconflow.cn/v1/chat/completions";
5+
const SILICONFLOW_MODEL = "Qwen/Qwen2.5-7B-Instruct";
6+
const SILICONFLOW_API_KEY = "sk-avkivvbgozinofsnrfuazmsfhiuxlyimewadbfmvghilfkax";
7+
const REQUEST_TIMEOUT_MS = 6000;
8+
const MAX_TITLE_LENGTH = 64;
9+
10+
const inFlight = new Set<string>();
11+
12+
type SiliconFlowResponse = {
13+
choices?: Array<{
14+
message?: {
15+
content?: string;
16+
};
17+
}>;
18+
};
19+
20+
function normalizeTitle(value: string): string | null {
21+
const title = value
22+
.replace(/^[`"'\s]+|[`"'\s]+$/g, "")
23+
.replace(/\s+/g, " ")
24+
.trim();
25+
if (!title) return null;
26+
if (title.length <= MAX_TITLE_LENGTH) return title;
27+
return title.slice(0, MAX_TITLE_LENGTH).trim();
28+
}
29+
30+
async function generateTitleFromPrompt(prompt: string): Promise<string | null> {
31+
const controller = new AbortController();
32+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
33+
try {
34+
const response = await fetch(SILICONFLOW_API_URL, {
35+
method: "POST",
36+
headers: {
37+
"Content-Type": "application/json",
38+
Authorization: `Bearer ${SILICONFLOW_API_KEY}`,
39+
},
40+
body: JSON.stringify({
41+
model: SILICONFLOW_MODEL,
42+
temperature: 0.2,
43+
max_tokens: 24,
44+
messages: [
45+
{
46+
role: "system",
47+
content: [
48+
"You generate concise chat session titles.",
49+
"Rules:",
50+
"- Return title text only.",
51+
"- No quotes.",
52+
"- Keep it under 12 words.",
53+
].join("\n"),
54+
},
55+
{
56+
role: "user",
57+
content: prompt,
58+
},
59+
],
60+
}),
61+
signal: controller.signal,
62+
});
63+
64+
if (!response.ok) {
65+
log.debug("SiliconFlow title generation failed", { status: response.status });
66+
return null;
67+
}
68+
69+
const data = await response.json() as SiliconFlowResponse;
70+
const content = data.choices?.[0]?.message?.content;
71+
if (typeof content !== "string") return null;
72+
return normalizeTitle(content);
73+
} catch (error) {
74+
log.debug("SiliconFlow title generation error", { error: String(error) });
75+
return null;
76+
} finally {
77+
clearTimeout(timeout);
78+
}
79+
}
80+
81+
function createTitleOnlyState(title: string, startedAt: number): SessionMessageState {
82+
return {
83+
sessionTitle: title,
84+
currentText: "",
85+
tools: [],
86+
todos: [],
87+
startedAt,
88+
};
89+
}
90+
91+
export async function maybeGenerateSessionTitle(params: {
92+
prompt: string;
93+
stateKey: string;
94+
liveParsedState: Map<string, SessionMessageState>;
95+
startedAt: number;
96+
}): Promise<void> {
97+
const { prompt, stateKey, liveParsedState, startedAt } = params;
98+
if (!prompt.trim()) return;
99+
if (inFlight.has(stateKey)) return;
100+
101+
const existingState = liveParsedState.get(stateKey);
102+
if (existingState?.sessionTitle) return;
103+
104+
inFlight.add(stateKey);
105+
try {
106+
const title = await generateTitleFromPrompt(prompt);
107+
if (!title) return;
108+
109+
const nextState = liveParsedState.get(stateKey);
110+
if (nextState) {
111+
if (!nextState.sessionTitle) {
112+
nextState.sessionTitle = title;
113+
}
114+
return;
115+
}
116+
117+
liveParsedState.set(stateKey, createTitleOnlyState(title, startedAt));
118+
} finally {
119+
inFlight.delete(stateKey);
120+
}
121+
}

packages/core/test/status-message.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe("buildStatusMessageForAgent", () => {
4444
expect(text).not.toBe("custom status");
4545
});
4646

47-
it("uses fallback header for non-opencode providers when title is missing", () => {
47+
it("uses opencode fallback header when title is missing", () => {
4848
const agent = {
4949
getProviderForSession: () => "codex",
5050
buildStatusMessage: () => "custom status",
@@ -61,10 +61,10 @@ describe("buildStatusMessageForAgent", () => {
6161
statusMessageFormat: "medium",
6262
});
6363

64-
expect(text).toContain("*Codex Working...*");
64+
expect(text).toContain("*Opencode is running...*");
6565
});
6666

67-
it("does not inject fallback header for opencode", () => {
67+
it("uses fallback header for opencode when title is missing", () => {
6868
const agent = {
6969
getProviderForSession: () => "opencode",
7070
buildStatusMessage: () => "custom status",
@@ -81,7 +81,7 @@ describe("buildStatusMessageForAgent", () => {
8181
statusMessageFormat: "medium",
8282
});
8383

84-
expect(text).not.toContain("Working...");
84+
expect(text).toContain("*Opencode is running...*");
8585
expect(text).toContain("_Thinking_");
8686
});
8787
});

packages/live-status-harness/test/render-status.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe("live status harness renderer", () => {
6565
const joined = statuses.map((status) => status.text).join("\n\n");
6666

6767
expect(statuses.length).toBeGreaterThanOrEqual(2);
68-
expect(joined).toContain("Goose Working...");
68+
expect(joined).toContain("Opencode is running...");
6969
expect(joined).toContain("Finished tool: Read");
7070
});
7171

@@ -120,7 +120,7 @@ describe("live status harness renderer", () => {
120120
const joined = statuses.map((status) => status.text).join("\n\n");
121121

122122
expect(statuses.length).toBeGreaterThanOrEqual(2);
123-
expect(joined).toContain("Gemini Working...");
123+
expect(joined).toContain("Opencode is running...");
124124
expect(joined).toContain("Running tool: read_file");
125125
});
126126
});

packages/utils/status.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,7 @@ export type StatusRequest = {
1414
};
1515

1616
export type AgentStatusProvider = "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini";
17-
18-
const PROVIDER_FALLBACK_TITLES: Partial<Record<AgentStatusProvider, string>> = {
19-
claudecode: "Claude Code Working...",
20-
codex: "Codex Working...",
21-
kimi: "Kimi Working...",
22-
kiro: "Kiro Working...",
23-
kilo: "Kilo Working...",
24-
qwen: "Qwen Working...",
25-
goose: "Goose Working...",
26-
gemini: "Gemini Working...",
27-
};
17+
const DEFAULT_FALLBACK_TITLE = "Opencode is running...";
2818

2919
type StatusTodo = {
3020
content: string;
@@ -295,7 +285,7 @@ export function buildLiveStatusMessage(
295285
if (request.statusFrozen && request.currentText) {
296286
return request.currentText;
297287
}
298-
return `_Working_ (${formatElapsedTime(request.startedAt)})`;
288+
return `${DEFAULT_FALLBACK_TITLE} (${formatElapsedTime(request.startedAt)})`;
299289
}
300290

301291
if (request.statusFrozen && request.currentText) {
@@ -308,7 +298,7 @@ export function buildLiveStatusMessage(
308298
if (state.sessionTitle) {
309299
lines.push(`*${state.sessionTitle}* (${headerDetails})`);
310300
} else {
311-
lines.push(`_${headerDetails}_`);
301+
lines.push(`*${DEFAULT_FALLBACK_TITLE}* (${headerDetails})`);
312302
}
313303

314304
if (state.phaseStatus) {
@@ -333,27 +323,11 @@ export function buildLiveStatusMessage(
333323
}
334324

335325
export function buildStatusMessageByProvider(
336-
provider: AgentStatusProvider,
326+
_provider: AgentStatusProvider,
337327
request: StatusRequest,
338328
workingPath: string,
339329
state?: SessionMessageState,
340330
statusMessageFormat: StatusMessageFormat = "medium"
341331
): string {
342-
const fallbackTitle = state && !state.sessionTitle
343-
? PROVIDER_FALLBACK_TITLES[provider]
344-
: undefined;
345-
346-
if (fallbackTitle && state) {
347-
return buildLiveStatusMessage(
348-
request,
349-
workingPath,
350-
{
351-
...state,
352-
sessionTitle: fallbackTitle,
353-
},
354-
statusMessageFormat
355-
);
356-
}
357-
358332
return buildLiveStatusMessage(request, workingPath, state, statusMessageFormat);
359333
}

0 commit comments

Comments
 (0)