fix(praisonai-ts): preserve restored tool name and invalidate cache on setHistory

Two fixes to the restorable-conversation feature:

- Preserve the tool `name` through getHistory/setHistory. toAISDKPrompt sets
  the tool-result part's toolName from msg.name; without it a restored tool
  history sent to a non-OpenAI (AI SDK) backend emits an empty toolName and is
  rejected. Added optional name to AgentMessage, copied it in copyMessage, and
  carried it in the start() replay.
- Clear responseCache in setHistory so a repeat prompt is re-evaluated against
  the restored history instead of returning the pre-restore prompt-keyed answer.

Added regression tests for both; full suite: 1141 passed, 0 failed.

Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
praisonai-triage-agent[bot]andMervin Praison committed 2026-08-27 14:30:43 +00:00
1 parent 77f88b8f23
commit 71197fa808
2 files changed
+81 -4

No files matched your search

+23 -3
View File
@@ -86,6 +86,14 @@ export interface AgentMessage {
}>;
/** Present on a tool turn; pairs it to the `tool_calls` entry above. */
readonly tool_call_id?: string;
/**
* The tool's name, present on a `tool` turn. Required so the AI SDK adapter
* (`toAISDKPrompt`) can set a non-empty `toolName` on the tool-result part —
* without it, a restored tool history sent to a non-OpenAI provider is
* rejected. The live tool loop already records this (`processToolCalls`); it
* must survive the getHistory/setHistory round-trip too.
*/
readonly name?: string;
}
export interface SimpleAgentConfig {
@@ -261,7 +269,7 @@ export class Agent {
private dbAdapter?: DbAdapter;
private sessionId: string;
private runId: string;
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
private messages: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }> = [];
private dbInitialized: boolean = false;
private historyLimit: number;
private autoRestore: boolean;
@@ -845,6 +853,9 @@ export class Agent {
const replay: any = { role: msg.role, content: msg.content };
if (msg.tool_calls) replay.tool_calls = msg.tool_calls;
if (msg.tool_call_id) replay.tool_call_id = msg.tool_call_id;
// Carry the tool name so a restored tool result keeps a non-empty
// toolName on the AI SDK path (toAISDKPrompt reads msg.name).
if (msg.name) replay.name = msg.name;
messages.push(replay);
}
@@ -1427,7 +1438,7 @@ export class Agent {
const validRoles = new Set(['system', 'user', 'assistant', 'tool']);
const knownToolCallIds = new Set<string>();
const restored: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }> = [];
const restored: Array<{ role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }> = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
@@ -1459,13 +1470,17 @@ export class Agent {
}
this.messages = restored;
// Replacing the conversation invalidates any cached answers keyed by prompt
// alone: a repeat prompt must be re-evaluated against the restored history,
// not served the previous conversation's response.
this.responseCache.clear();
}
/**
* Deep-copy a single message so neither getHistory() nor setHistory() shares
* the caller's mutable references (tool_calls is an array of objects).
*/
private copyMessage(m: AgentMessage | { role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string }): any {
private copyMessage(m: AgentMessage | { role: string; content: string | null; tool_calls?: any[]; tool_call_id?: string; name?: string }): any {
const copy: any = { role: m.role, content: m.content };
if (m.tool_calls) {
copy.tool_calls = m.tool_calls.map((c: any) => ({
@@ -1477,6 +1492,11 @@ export class Agent {
if (m.tool_call_id) {
copy.tool_call_id = m.tool_call_id;
}
// Preserve the tool name so a restored tool result keeps a non-empty
// toolName when converted for the AI SDK backend (adapter.ts toAISDKPrompt).
if (m.name) {
copy.name = m.name;
}
return copy;
}
@@ -23,7 +23,7 @@ function toolConversation(): AgentMessage[] {
{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"Paris"}' } },
],
},
{ role: 'tool', content: '20C and sunny', tool_call_id: 'call_1' },
{ role: 'tool', content: '20C and sunny', tool_call_id: 'call_1', name: 'getWeather' },
{ role: 'assistant', content: 'It is 20C and sunny in Paris.' },
];
}
@@ -114,6 +114,63 @@ describe('getHistory()/setHistory() conversation restore', () => {
expect(again.length).toBe(4);
});
it('preserves the tool name so a restored tool result reaches the provider with toolName intact', async () => {
let captured: any[] | null = null;
jest
.spyOn(OpenAIService.prototype as any, 'generateChat')
.mockImplementation(async (...args: any[]) => {
captured = args[0];
return { content: 'ok' };
});
const agent = new Agent({ instructions: 'x', llm: 'openai/gpt-4o-mini', stream: false, verbose: false });
agent.setHistory(toolConversation());
// Round-trip keeps the tool name (adapter.ts toAISDKPrompt reads msg.name).
const toolMsg = agent.getHistory().find((m) => m.role === 'tool');
expect(toolMsg!.name).toBe('getWeather');
await agent.start('and tomorrow?');
const replayedTool = captured!.find((m) => m.role === 'tool');
expect(replayedTool.name).toBe('getWeather');
jest.restoreAllMocks();
});
it('clears the response cache so a repeat prompt is re-evaluated against restored history', async () => {
// The no-history path uses generateText; the post-restore path uses
// generateChat. Mock both and count total provider calls to prove the
// second prompt was NOT served from the prompt-keyed cache.
const genText = jest
.spyOn(OpenAIService.prototype as any, 'generateText')
.mockResolvedValue('fresh');
const genChat = jest
.spyOn(OpenAIService.prototype as any, 'generateChat')
.mockResolvedValue({ content: 'fresh' });
const agent = new Agent({
instructions: 'x',
llm: 'openai/gpt-4o-mini',
stream: false,
verbose: false,
cache: true,
});
const first = await agent.chat('same prompt');
expect(first).toBe('fresh');
expect(genText.mock.calls.length + genChat.mock.calls.length).toBe(1);
// Restoring a different conversation must invalidate the prompt-keyed cache;
// otherwise the repeat prompt returns the pre-restore answer without hitting
// the model against the newly restored history.
agent.setHistory([{ role: 'user', content: 'earlier' }, { role: 'assistant', content: 'earlier reply' }]);
await agent.chat('same prompt');
expect(genText.mock.calls.length + genChat.mock.calls.length).toBe(2);
jest.restoreAllMocks();
});
it('replays the restored messages to the provider on the next start()', async () => {
let captured: any[] | null = null;
jest