Skip to content

Commit 18da797

Browse files
committed
test(core): assert generate awaits title persistence
Regression for #20682: createThread is intentionally slow so a detached void genTitle would lose the race; generate must not resolve until the title is persisted.
1 parent ad15c83 commit 18da797

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

‎packages/core/src/agent/__tests__/title-generation.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,146 @@ function titleGenerationTests(version: 'v1' | 'v2') {
814814
expect(agentCallCount).toBe(1); // But main agent should still be called
815815
});
816816

817+
it('awaits title persistence before generate resolves (#20682)', async () => {
818+
// Regression: detached void genTitle() never ran on serverless runtimes that
819+
// freeze after the HTTP response. generate()/generateLegacy() must not
820+
// resolve until the title has been persisted — no post-await setTimeout.
821+
let titlePersisted = false;
822+
823+
const mockMemory = new MockMemory();
824+
mockMemory.getMergedThreadConfig = () => ({
825+
generateTitle: true,
826+
});
827+
828+
const originalCreateThread = mockMemory.createThread.bind(mockMemory);
829+
mockMemory.createThread = async args => {
830+
// Intentionally slow so a detached fire-and-forget would lose the race.
831+
await new Promise(resolve => setTimeout(resolve, 40));
832+
const thread = await originalCreateThread(args);
833+
if (args.title) {
834+
titlePersisted = true;
835+
}
836+
return thread;
837+
};
838+
839+
let testModel: MockLanguageModelV1 | MockLanguageModelV2;
840+
841+
if (version === 'v1') {
842+
testModel = new MockLanguageModelV1({
843+
doGenerate: async options => {
844+
const messages = options.prompt;
845+
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));
846+
847+
if (isForTitle) {
848+
return {
849+
rawCall: { rawPrompt: null, rawSettings: {} },
850+
finishReason: 'stop',
851+
usage: { promptTokens: 5, completionTokens: 10 },
852+
text: 'Serverless Safe Title',
853+
};
854+
}
855+
856+
return {
857+
rawCall: { rawPrompt: null, rawSettings: {} },
858+
finishReason: 'stop',
859+
usage: { promptTokens: 10, completionTokens: 20 },
860+
text: 'Agent Response',
861+
};
862+
},
863+
});
864+
} else {
865+
testModel = new MockLanguageModelV2({
866+
doGenerate: async options => {
867+
const messages = options.prompt;
868+
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));
869+
870+
if (isForTitle) {
871+
return {
872+
rawCall: { rawPrompt: null, rawSettings: {} },
873+
finishReason: 'stop',
874+
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
875+
text: 'Serverless Safe Title',
876+
content: [{ type: 'text', text: 'Serverless Safe Title' }],
877+
warnings: [],
878+
};
879+
}
880+
881+
return {
882+
rawCall: { rawPrompt: null, rawSettings: {} },
883+
finishReason: 'stop',
884+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
885+
text: 'Agent Response',
886+
content: [{ type: 'text', text: 'Agent Response' }],
887+
warnings: [],
888+
};
889+
},
890+
doStream: async options => {
891+
const messages = options.prompt;
892+
const isForTitle = messages.some((msg: any) => msg.content?.includes?.('you will generate a short title'));
893+
const text = isForTitle ? 'Serverless Safe Title' : 'Agent Response';
894+
895+
return {
896+
rawCall: { rawPrompt: null, rawSettings: {} },
897+
warnings: [],
898+
stream: convertArrayToReadableStream([
899+
{ type: 'stream-start', warnings: [] },
900+
{
901+
type: 'response-metadata',
902+
id: 'id-0',
903+
modelId: 'mock-model-id',
904+
timestamp: new Date(0),
905+
},
906+
{ type: 'text-start', id: 'text-1' },
907+
{ type: 'text-delta', id: 'text-1', delta: text },
908+
{ type: 'text-end', id: 'text-1' },
909+
{
910+
type: 'finish',
911+
finishReason: 'stop',
912+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
913+
},
914+
]),
915+
};
916+
},
917+
});
918+
}
919+
920+
const agent = new Agent({
921+
id: 'await-title-agent',
922+
name: 'Await Title Agent',
923+
instructions: 'test agent',
924+
model: testModel,
925+
memory: mockMemory,
926+
});
927+
928+
const threadId = `thread-await-title-${version}`;
929+
930+
if (version === 'v1') {
931+
await agent.generateLegacy('Name this conversation', {
932+
memory: {
933+
resource: 'user-await',
934+
thread: {
935+
id: threadId,
936+
title: '',
937+
},
938+
},
939+
});
940+
} else {
941+
await agent.generate('Name this conversation', {
942+
memory: {
943+
resource: 'user-await',
944+
thread: {
945+
id: threadId,
946+
title: '',
947+
},
948+
},
949+
});
950+
}
951+
952+
expect(titlePersisted).toBe(true);
953+
const thread = await mockMemory.getThreadById({ threadId });
954+
expect(thread?.title).toBe('Serverless Safe Title');
955+
});
956+
817957
it('should not generate title for pre-created threads (thread already exists)', async () => {
818958
// Pre-created threads already exist in the DB, so threadExists is true.
819959
// Title generation only fires when the thread is newly created by the agent.

0 commit comments

Comments
 (0)