Skip to content

Commit 6c834f8

Browse files
authored
fix(runtime): Properly derive thread names from agents when generating them (fix ENT-732) (CopilotKit#5156)
## Summary - Select generated thread titles only from assistant text messages returned by the naming run. - Reject JSON-shaped title output unless it parses to an object with a string title. - Add Runtime regression coverage for tool-result suffixes and invalid JSON title payloads. ## Validation - pnpm nx run @copilotkit/runtime:test -- src/v2/runtime/__tests__/thread-names.test.ts src/v2/runtime/__tests__/handle-run.test.ts (passed, 56 tests) - pnpm nx run @copilotkit/runtime:build (passed) - Pre-commit package checks passed - pnpm nx run @copilotkit/runtime:check-types (blocked in dependency task @copilotkit/shared:check-types: missing LicenseContextValue/LicenseMode exports from @copilotkit/license-verifier and telemetry index error) - NODE_OPTIONS=--max-old-space-size=8192 pnpm nx run @copilotkit/runtime:check-types --excludeTaskDependencies (failed: tsc heap out of memory near 8 GB)
2 parents dfc72f1 + 1f3c5fb commit 6c834f8

3 files changed

Lines changed: 93 additions & 20 deletions

File tree

packages/runtime/src/v2/runtime/__tests__/handle-run.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { Observable } from "rxjs";
22
import { describe, it, expect, vi } from "vitest";
3-
import {
4-
AbstractAgent,
5-
BaseEvent,
6-
EventType,
7-
HttpAgent,
8-
RunAgentInput,
9-
} from "@ag-ui/client";
3+
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
4+
import { AbstractAgent, EventType, HttpAgent } from "@ag-ui/client";
105
import { A2UIMiddleware } from "@ag-ui/a2ui-middleware";
116
import { handleRunAgent } from "../handlers/handle-run";
127
import { CopilotRuntime } from "../core/runtime";
@@ -935,6 +930,11 @@ describe("handleRunAgent", () => {
935930
role: "assistant",
936931
content: '{"title":"**Order refund** status"}',
937932
},
933+
{
934+
id: "tool-1",
935+
role: "tool",
936+
content: '{"timezone":"UTC","iso":"2026-06-01T00:00:00Z"}',
937+
},
938938
],
939939
}),
940940
} as unknown as AbstractAgent;

packages/runtime/src/v2/runtime/__tests__/thread-names.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Message } from "@ag-ui/client";
33

44
import {
55
ɵnormalizeGeneratedTitle as normalizeGeneratedTitle,
6+
ɵselectGeneratedTitleFromMessages as selectGeneratedTitleFromMessages,
67
ɵbuildThreadTitlePrompt as buildThreadTitlePrompt,
78
ɵhasThreadName as hasThreadName,
89
} from "../handlers/intelligence/thread-names";
@@ -80,12 +81,56 @@ describe("normalizeGeneratedTitle", () => {
8081
});
8182

8283
it("handles JSON with non-string title gracefully", () => {
83-
// Falls back to raw text path
84-
expect(normalizeGeneratedTitle('{"title":42}')).toBe('{"title":42}');
84+
expect(normalizeGeneratedTitle('{"title":42}')).toBeNull();
85+
});
86+
87+
it("rejects JSON without a string title", () => {
88+
expect(
89+
normalizeGeneratedTitle(
90+
'{"timezone":"UTC","iso":"2026-06-01T00:00:00Z"}',
91+
),
92+
).toBeNull();
8593
});
8694

8795
it("handles malformed JSON gracefully", () => {
88-
expect(normalizeGeneratedTitle("{not json}")).toBe("{not json}");
96+
expect(normalizeGeneratedTitle("{not json}")).toBeNull();
97+
});
98+
});
99+
100+
// ---------------------------------------------------------------------------
101+
// selectGeneratedTitleFromMessages
102+
// ---------------------------------------------------------------------------
103+
104+
describe("selectGeneratedTitleFromMessages", () => {
105+
const msg = (role: string, content: unknown): Message =>
106+
({ id: `msg-${role}`, role, content }) as Message;
107+
108+
it("uses the latest valid assistant title before a tool result", () => {
109+
const result = selectGeneratedTitleFromMessages([
110+
msg("assistant", '{"title":"Weather request"}'),
111+
msg("tool", '{"timezone":"UTC","iso":"2026-06-01T00:00:00Z"}'),
112+
]);
113+
114+
expect(result).toBe("Weather request");
115+
});
116+
117+
it("ignores malformed assistant JSON and falls back to an earlier valid title", () => {
118+
const result = selectGeneratedTitleFromMessages([
119+
msg("assistant", '{"title":"Order refund"}'),
120+
msg("assistant", '{"timezone":"UTC"}'),
121+
]);
122+
123+
expect(result).toBe("Order refund");
124+
});
125+
126+
it("returns null when no assistant text response has a valid title", () => {
127+
const result = selectGeneratedTitleFromMessages([
128+
msg("tool", '{"title":"Tool payload"}'),
129+
msg("assistant", { title: "Object payload" }),
130+
msg("assistant", '{"title":42}'),
131+
]);
132+
133+
expect(result).toBeNull();
89134
});
90135
});
91136

packages/runtime/src/v2/runtime/handlers/intelligence/thread-names.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { AbstractAgent, Message, RunAgentInput } from "@ag-ui/client";
1+
import type { Message, RunAgentInput } from "@ag-ui/client";
2+
import { AbstractAgent } from "@ag-ui/client";
23
import { logger } from "@copilotkit/shared";
34
import { randomUUID } from "node:crypto";
4-
import { CopilotIntelligenceRuntimeLike } from "../../core/runtime";
5+
import type { CopilotIntelligenceRuntimeLike } from "../../core/runtime";
56
import {
67
cloneAgentForRequest,
78
configureAgentForRequest,
89
} from "../shared/agent-utils";
9-
import { ThreadSummary } from "../../intelligence-platform";
10+
import type { ThreadSummary } from "../../intelligence-platform";
1011
import { isHandlerResponse } from "../shared/json-response";
1112

1213
const THREAD_NAME_SYSTEM_PROMPT = [
@@ -139,12 +140,7 @@ async function runTitleGenerationAttempt(params: {
139140
forwardedProps: {},
140141
});
141142

142-
const lastMessage = newMessages.at(-1);
143-
const titleContent = lastMessage
144-
? stringifyMessageContent(lastMessage.content)
145-
: "";
146-
147-
return normalizeGeneratedTitle(titleContent);
143+
return selectGeneratedTitleFromMessages(newMessages);
148144
}
149145

150146
function buildThreadTitlePrompt(
@@ -203,13 +199,19 @@ function normalizeGeneratedTitle(rawTitle: string): string | null {
203199
.replace(/\s*```$/, "")
204200
.trim();
205201

202+
const jsonLike = isJsonLike(candidate);
203+
206204
try {
207205
const parsed = JSON.parse(candidate) as { title?: unknown };
208206
if (typeof parsed.title === "string") {
209207
candidate = parsed.title;
208+
} else if (jsonLike) {
209+
return null;
210210
}
211211
} catch {
212-
// Fall back to using the raw text.
212+
if (jsonLike) {
213+
return null;
214+
}
213215
}
214216

215217
candidate = candidate
@@ -234,13 +236,39 @@ function normalizeGeneratedTitle(rawTitle: string): string | null {
234236
return candidate;
235237
}
236238

239+
function selectGeneratedTitleFromMessages(messages: Message[]): string | null {
240+
for (let index = messages.length - 1; index >= 0; index--) {
241+
const message = messages[index];
242+
if (message.role !== "assistant" || typeof message.content !== "string") {
243+
continue;
244+
}
245+
246+
const title = normalizeGeneratedTitle(message.content);
247+
if (title) {
248+
return title;
249+
}
250+
}
251+
252+
return null;
253+
}
254+
255+
function isJsonLike(candidate: string): boolean {
256+
return (
257+
(candidate.startsWith("{") && candidate.endsWith("}")) ||
258+
(candidate.startsWith("[") && candidate.endsWith("]"))
259+
);
260+
}
261+
237262
function hasThreadName(name: string | null | undefined): boolean {
238263
return typeof name === "string" && name.trim().length > 0;
239264
}
240265

241266
/** @internal Exported for testing only. */
242267
export const ɵnormalizeGeneratedTitle = normalizeGeneratedTitle;
243268
/** @internal Exported for testing only. */
269+
export const ɵselectGeneratedTitleFromMessages =
270+
selectGeneratedTitleFromMessages;
271+
/** @internal Exported for testing only. */
244272
export const ɵbuildThreadTitlePrompt = buildThreadTitlePrompt;
245273
/** @internal Exported for testing only. */
246274
export const ɵhasThreadName = hasThreadName;

0 commit comments

Comments
 (0)