fix(context): Fix snapshot recovery across sessions. (#26939)

This commit is contained in:
joshualitt
2026-05-18 09:44:59 -07:00
committed by GitHub
parent 9d01958cdb
commit 055e0f6452
57 changed files with 3143 additions and 751 deletions
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { deriveStableId } from './cryptoUtils.js';
describe('cryptoUtils', () => {
describe('deriveStableId', () => {
it('should be deterministic regardless of input order', () => {
const id1 = deriveStableId(['a', 'b', 'c']);
const id2 = deriveStableId(['c', 'b', 'a']);
expect(id1).toBe(id2);
expect(id1).toMatch(/^[0-9a-f]{32}$/);
});
it('should produce different IDs for different inputs', () => {
const id1 = deriveStableId(['a', 'b', 'c']);
const id2 = deriveStableId(['a', 'b', 'd']);
expect(id1).not.toBe(id2);
});
it('should handle single inputs', () => {
const id = deriveStableId(['only-one']);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
it('should be consistent across calls with same data', () => {
const input = ['id-123', 'id-456'];
expect(deriveStableId(input)).toBe(deriveStableId(input));
});
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
/**
* Derives a stable, deterministic ID from a list of source IDs.
* Used for synthetic turns like summaries to ensure that re-summarizing the same
* content produces a consistent identity.
*/
export function deriveStableId(sourceIds: string[]): string {
const sortedIds = [...sourceIds].sort();
return createHash('sha256')
.update(sortedIds.join('|'))
.digest('hex')
.slice(0, 32);
}
@@ -7,6 +7,7 @@
import type { Part, Content } from '@google/genai';
import type { Config } from '../config/config.js';
import { getFolderStructure } from './getFolderStructure.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
export const INITIAL_HISTORY_LENGTH = 1;
@@ -81,8 +82,8 @@ ${environmentMemory}
export async function getInitialChatHistory(
config: Config,
extraHistory?: Content[],
): Promise<Content[]> {
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
): Promise<Array<Content | HistoryTurn>> {
const envParts = await getEnvironmentContext(config);
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
@@ -0,0 +1,378 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
hardenHistory,
SYNTHETIC_THOUGHT_SIGNATURE,
} from './historyHardening.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
import type { Part } from '@google/genai';
describe('hardenHistory', () => {
it('should return an empty array if input is empty', () => {
expect(hardenHistory([])).toEqual([]);
});
it('should coalesce adjacent turns of the same role', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(1);
expect(hardened[0].content.parts).toEqual([
{ text: 'hello' },
{ text: 'world' },
]);
expect(hardened[0].id).toBe('1'); // Inherits ID of the first turn in the sequence
});
it('should inject thoughtSignature into the first functionCall of a model turn if missing', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [{ functionCall: { name: 'myTool', args: {} } }],
},
},
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: {
name: 'myTool',
response: { ok: true },
},
},
],
},
},
];
const hardened = hardenHistory(history);
const modelPart = hardened[1].content.parts![0];
expect(modelPart).toHaveProperty(
'thoughtSignature',
SYNTHETIC_THOUGHT_SIGNATURE,
);
});
it('should inject a sentinel user turn if history ends with a model turn', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(3);
expect(hardened[2].content.role).toBe('user');
expect(hardened[2].content.parts![0]).toEqual({ text: 'Please continue.' });
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_end']));
});
it('should inject a sentinel user turn if history starts with a model turn', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'model', parts: [{ text: 'hi' }] } },
{ id: '2', content: { role: 'user', parts: [{ text: 'hello' }] } },
];
const hardened = hardenHistory(history, {
sentinels: { continuation: 'Custom start' },
});
expect(hardened.length).toBe(3);
expect(hardened[0].content.role).toBe('user');
expect(hardened[0].content.parts![0]).toEqual({ text: 'Custom start' });
expect(hardened[0].id).toBe(deriveStableId(['1', 'sentinel_start']));
});
it('should inject sentinel responses for missing functionResponses', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'call_1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
],
},
},
// Note: Turn 3 is missing, so toolA and toolB have no responses
];
const hardened = hardenHistory(history, {
sentinels: { lostToolResponse: 'Lost.' },
});
// The history should now be: User -> Model -> User (sentinel responses) -> User (sentinel end)
// Wait, the sentinel responses turn will satisfy the "ends with user" rule.
expect(hardened.length).toBe(3);
expect(hardened[2].content.role).toBe('user');
expect(hardened[2].content.parts).toHaveLength(2);
const resp1 = hardened[2].content.parts![0].functionResponse;
expect(resp1?.id).toBe('call_1');
expect(resp1?.response).toEqual({ error: 'Lost.' });
const resp2 = hardened[2].content.parts![1].functionResponse;
expect(resp2?.id).toBe('call_2');
expect(resp2?.response).toEqual({ error: 'Lost.' });
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_resp']));
});
it('should successfully match parallel tool calls and responses even if responses are originally split across separate user turns', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'call_1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
],
},
},
// Responses arrive as separate user turns
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'toolA',
response: { ok: true },
},
},
],
},
},
{
id: '4',
content: {
role: 'user',
parts: [
{
functionResponse: {
id: 'call_2',
name: 'toolB',
response: { ok: true },
},
},
],
},
},
];
// The hardener should coalesce Turn 3 and Turn 4 *before* it tries to pair them with Turn 2.
// Otherwise, it would look at Turn 3, see 'call_2' is missing, inject a sentinel for 'call_2',
// and then look at Turn 4 and consider 'call_2' to be orphaned.
const hardened = hardenHistory(history);
// Total turns: User(1), Model(2), User(3+4 merged)
expect(hardened.length).toBe(3);
const userResponseTurn = hardened[2];
expect(userResponseTurn.content.role).toBe('user');
expect(userResponseTurn.content.parts).toHaveLength(2);
// Verify no sentinels were injected and original responses were preserved
expect(userResponseTurn.content.parts![0].functionResponse?.id).toBe(
'call_1',
);
expect(userResponseTurn.content.parts![1].functionResponse?.id).toBe(
'call_2',
);
// Ensure no error properties exist
expect(
userResponseTurn.content.parts![0].functionResponse?.response,
).toEqual({ ok: true });
expect(
userResponseTurn.content.parts![1].functionResponse?.response,
).toEqual({ ok: true });
});
it('should synthesize a functionCall for a singleton orphaned functionResponse', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
{
id: '3',
content: {
role: 'user',
parts: [
{ text: 'text is kept' },
{
functionResponse: { id: 'orphan_1', name: 'toolA', response: {} },
},
],
},
},
];
const hardened = hardenHistory(history);
// Turn 1: user, Turn 2: model (with synthetic call), Turn 3: user
expect(hardened.length).toBe(3);
const modelTurn = hardened[1];
expect(modelTurn.content.role).toBe('model');
expect(modelTurn.content.parts).toHaveLength(2); // text + synthetic call
expect(modelTurn.content.parts![1].functionCall).toBeDefined();
expect(modelTurn.content.parts![1].functionCall?.id).toBe('orphan_1');
expect(
(modelTurn.content.parts![1] as unknown as { thoughtSignature: string })
.thoughtSignature,
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
const userTurn = hardened[2];
expect(userTurn.content.parts).toHaveLength(2); // hoisted response + text
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_1');
expect(userTurn.content.parts![1]).toEqual({ text: 'text is kept' });
});
it('should synthesize functionCalls for multiple orphaned functionResponses in parallel', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: { role: 'user', parts: [{ text: 'Parallel action' }] },
},
// Previous model turn exists but has NO tool calls
{
id: '2',
content: { role: 'model', parts: [{ text: 'I will do nothing' }] },
},
{
id: '3',
content: {
role: 'user',
parts: [
{
functionResponse: { id: 'orphan_A', name: 'toolA', response: {} },
},
{
functionResponse: { id: 'orphan_B', name: 'toolB', response: {} },
},
{
functionResponse: { id: 'orphan_C', name: 'toolC', response: {} },
},
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened.length).toBe(3);
const modelTurn = hardened[1];
expect(modelTurn.content.role).toBe('model');
expect(modelTurn.content.parts).toHaveLength(4); // original text + 3 synthetic calls
// Only the FIRST function call should get the synthetic signature
const callA = modelTurn.content.parts![1];
expect(callA.functionCall?.id).toBe('orphan_A');
expect(
(callA as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
const callB = modelTurn.content.parts![2];
expect(callB.functionCall?.id).toBe('orphan_B');
expect(
(callB as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBeUndefined();
const callC = modelTurn.content.parts![3];
expect(callC.functionCall?.id).toBe('orphan_C');
expect(
(callC as unknown as { thoughtSignature?: string }).thoughtSignature,
).toBeUndefined();
const userTurn = hardened[2];
expect(userTurn.content.parts).toHaveLength(3);
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_A');
expect(userTurn.content.parts![1].functionResponse?.id).toBe('orphan_B');
expect(userTurn.content.parts![2].functionResponse?.id).toBe('orphan_C');
});
it('should hoist and re-order tool responses to match functionCall order', () => {
const history: HistoryTurn[] = [
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
{
id: '2',
content: {
role: 'model',
parts: [
{
functionCall: { id: 'c1', name: 'toolA', args: {} },
thoughtSignature: 'sig',
},
{ functionCall: { id: 'c2', name: 'toolB', args: {} } },
],
},
},
{
id: '3',
content: {
role: 'user',
parts: [
{ text: 'some text' },
{ functionResponse: { id: 'c2', name: 'toolB', response: {} } },
{ functionResponse: { id: 'c1', name: 'toolA', response: {} } },
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened[2].content.parts).toHaveLength(3);
// Order should be: resp(c1) -> resp(c2) -> text
const p0 = hardened[2].content.parts![0];
const p1 = hardened[2].content.parts![1];
const p2 = hardened[2].content.parts![2];
expect(p0.functionResponse?.id).toBe('c1');
expect(p1.functionResponse?.id).toBe('c2');
expect(p2.text).toBe('some text');
});
it('should scrub non-standard properties from parts', () => {
const history: HistoryTurn[] = [
{
id: '1',
content: {
role: 'user',
parts: [
{
text: 'hello',
extraProp: 'should be removed',
} as unknown as Part,
],
},
},
];
const hardened = hardenHistory(history);
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
});
});
+101 -48
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import { type Part } from '@google/genai';
import { debugLogger } from './debugLogger.js';
import { type HistoryTurn } from '../core/agentChatHistory.js';
import { deriveStableId } from './cryptoUtils.js';
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
@@ -35,9 +37,9 @@ const DEFAULT_SENTINELS = {
* 5. Signatures: The first functionCall in a model turn must have a thoughtSignature.
*/
export function hardenHistory(
history: Content[],
history: HistoryTurn[],
options: HardeningOptions = {},
): Content[] {
): HistoryTurn[] {
if (history.length === 0) return history;
const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels };
@@ -63,17 +65,20 @@ export function hardenHistory(
/**
* Combines adjacent turns with the same role and removes empty turns.
*/
function coalesce(history: Content[]): Content[] {
const result: Content[] = [];
function coalesce(history: HistoryTurn[]): HistoryTurn[] {
const result: HistoryTurn[] = [];
for (const turn of history) {
if (!turn.parts || turn.parts.length === 0) continue;
if (!turn.content.parts || turn.content.parts.length === 0) continue;
const last = result[result.length - 1];
if (last && last.role === turn.role) {
last.parts = [...(last.parts || []), ...(turn.parts || [])];
if (last && last.content.role === turn.content.role) {
last.content.parts = [
...(last.content.parts || []),
...(turn.content.parts || []),
];
} else {
// Shallow clone the turn so we don't mutate the original history array structure
result.push({ ...turn });
// Shallow clone the turn and content so we don't mutate the original history array structure
result.push({ id: turn.id, content: { ...turn.content } });
}
}
return result;
@@ -83,10 +88,10 @@ function coalesce(history: Content[]): Content[] {
* Ensures tool calls have matching responses and model turns have required signatures.
*/
function pairToolsAndEnforceSignatures(
history: Content[],
history: HistoryTurn[],
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
): Content[] {
const result: Content[] = [];
): HistoryTurn[] {
const result: HistoryTurn[] = [];
// We work on a copy to allow splicing in sentinel turns
const work = [...history];
@@ -94,8 +99,8 @@ function pairToolsAndEnforceSignatures(
for (let i = 0; i < work.length; i++) {
const turn = work[i];
if (turn.role === 'model') {
const parts = turn.parts || [];
if (turn.content.role === 'model') {
const parts = turn.content.parts || [];
// A. Signatures
let foundCall = false;
@@ -123,8 +128,8 @@ function pairToolsAndEnforceSignatures(
const name = call.functionCall!.name || 'unknown';
const hasResponse =
nextTurn?.role === 'user' &&
nextTurn.parts?.some(
nextTurn?.content.role === 'user' &&
nextTurn.content.parts?.some(
(p) =>
p.functionResponse?.id === id &&
p.functionResponse?.name === name,
@@ -143,17 +148,20 @@ function pairToolsAndEnforceSignatures(
`[HistoryHardener] Detected ${missing.length} tool calls without responses. Injecting sentinel responses.`,
);
let targetUserTurn: Content;
if (nextTurn?.role === 'user') {
let targetUserTurn: HistoryTurn;
if (nextTurn?.content.role === 'user') {
targetUserTurn = nextTurn;
} else {
targetUserTurn = { role: 'user', parts: [] };
targetUserTurn = {
id: deriveStableId([turn.id, 'sentinel_resp']),
content: { role: 'user', parts: [] },
};
work.splice(i + 1, 0, targetUserTurn);
}
for (const m of missing) {
targetUserTurn.parts = targetUserTurn.parts || [];
targetUserTurn.parts.push({
targetUserTurn.content.parts = targetUserTurn.content.parts || [];
targetUserTurn.content.parts.push({
functionResponse: {
name: m.name,
id: m.id,
@@ -165,20 +173,21 @@ function pairToolsAndEnforceSignatures(
}
}
}
} else if (turn.role === 'user') {
} else if (turn.content.role === 'user') {
// C. Orphaned Responses
// A user response MUST follow a model call.
const prevTurn = result[result.length - 1];
const parts = turn.parts || [];
const parts = turn.content.parts || [];
const validParts: Part[] = [];
const orphanedResponses: Part[] = [];
for (const p of parts) {
if (p.functionResponse) {
const id = p.functionResponse.id;
const name = p.functionResponse.name;
const hasCall =
prevTurn?.role === 'model' &&
prevTurn.parts?.some(
prevTurn?.content.role === 'model' &&
prevTurn.content.parts?.some(
(cp) =>
cp.functionCall?.id === id && cp.functionCall?.name === name,
);
@@ -187,17 +196,51 @@ function pairToolsAndEnforceSignatures(
validParts.push(p);
} else {
debugLogger.log(
`[HistoryHardener] Dropping orphaned functionResponse id='${id}' (name='${name}')`,
`[HistoryHardener] Orphaned functionResponse id='${id}' (name='${name}'). Injecting synthetic functionCall.`,
);
orphanedResponses.push(p);
validParts.push(p);
}
} else {
validParts.push(p);
}
}
turn.parts = validParts;
if (orphanedResponses.length > 0) {
let targetModelTurn: HistoryTurn;
if (prevTurn?.content.role === 'model') {
targetModelTurn = prevTurn;
} else {
targetModelTurn = {
id: deriveStableId([turn.id, 'sentinel_call']),
content: { role: 'model', parts: [] },
};
result.push(targetModelTurn);
}
for (const orph of orphanedResponses) {
targetModelTurn.content.parts = targetModelTurn.content.parts || [];
const hasExistingCall = targetModelTurn.content.parts.some(
(p) => !!p.functionCall,
);
const callPart: Part = {
functionCall: {
name: orph.functionResponse!.name,
id: orph.functionResponse!.id,
args: {},
},
};
if (!hasExistingCall) {
callPart.thoughtSignature = SYNTHETIC_THOUGHT_SIGNATURE;
}
targetModelTurn.content.parts.push(callPart);
}
}
turn.content.parts = validParts;
}
if (turn.parts && turn.parts.length > 0) {
if (turn.content.parts && turn.content.parts.length > 0) {
result.push(turn);
}
}
@@ -208,21 +251,22 @@ function pairToolsAndEnforceSignatures(
/**
* Hoists and re-orders tool responses within user turns to match preceding model turns.
*/
function refineToolResponses(history: Content[]): Content[] {
function refineToolResponses(history: HistoryTurn[]): HistoryTurn[] {
for (let i = 1; i < history.length; i++) {
const turn = history[i];
const prev = history[i - 1];
if (turn.role === 'user' && prev.role === 'model') {
if (turn.content.role === 'user' && prev.content.role === 'model') {
const callOrder =
prev.parts
prev.content.parts
?.filter((p) => !!p.functionCall)
.map((p) => p.functionCall!.id) || [];
if (callOrder.length > 0) {
const responseParts =
turn.parts?.filter((p) => !!p.functionResponse) || [];
const otherParts = turn.parts?.filter((p) => !p.functionResponse) || [];
turn.content.parts?.filter((p) => !!p.functionResponse) || [];
const otherParts =
turn.content.parts?.filter((p) => !p.functionResponse) || [];
if (responseParts.length > 0) {
// 1. Re-order: Sort responses to match the model's call order
@@ -240,7 +284,7 @@ function refineToolResponses(history: Content[]): Content[] {
});
// 2. Hoisting: Place all sorted responses BEFORE text or other parts
turn.parts = [...responseParts, ...otherParts];
turn.content.parts = [...responseParts, ...otherParts];
}
}
}
@@ -252,36 +296,42 @@ function refineToolResponses(history: Content[]): Content[] {
* Final pass to ensure start/end roles and alternation are correct.
*/
function enforceRoleConstraints(
history: Content[],
history: HistoryTurn[],
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
): Content[] {
): HistoryTurn[] {
if (history.length === 0) return [];
// Re-coalesce first to catch any empty turns or adjacent roles introduced by pairing
const base = coalesce(history);
if (base.length === 0) return [];
const result: Content[] = [...base];
const result: HistoryTurn[] = [...base];
// 1. Ensure starts with user
if (result[0].role === 'model') {
if (result[0].content.role === 'model') {
debugLogger.log(
'[HistoryHardener] Final history starts with model role. Prepending sentinel user turn.',
);
result.unshift({
role: 'user',
parts: [{ text: sentinels.continuation }],
id: deriveStableId([result[0].id, 'sentinel_start']),
content: {
role: 'user',
parts: [{ text: sentinels.continuation }],
},
});
}
// 2. Ensure ends with user
if (result[result.length - 1].role === 'model') {
if (result[result.length - 1].content.role === 'model') {
debugLogger.log(
'[HistoryHardener] Final history ends with model role. Appending sentinel user turn.',
);
result.push({
role: 'user',
parts: [{ text: 'Please continue.' }],
id: deriveStableId([result[result.length - 1].id, 'sentinel_end']),
content: {
role: 'user',
parts: [{ text: 'Please continue.' }],
},
});
}
@@ -293,10 +343,13 @@ function enforceRoleConstraints(
* Deep-scrubs the history to remove any non-standard properties from Content and Part objects.
* This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields.
*/
export function scrubHistory(history: Content[]): Content[] {
return history.map((content) => ({
role: content.role,
parts: (content.parts || []).map(scrubPart),
export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] {
return history.map((turn) => ({
id: turn.id,
content: {
role: turn.content.role,
parts: (turn.content.parts || []).map((p) => scrubPart(p)),
},
}));
}
+5 -5
View File
@@ -27,7 +27,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
]);
@@ -58,7 +58,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
{
role: 'model',
@@ -100,7 +100,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'Actual query' }] },
]);
});
@@ -133,7 +133,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{ role: 'user', parts: [{ text: 'List files' }] },
{
role: 'model',
@@ -172,7 +172,7 @@ describe('convertSessionToClientHistory', () => {
const history = convertSessionToClientHistory(messages);
expect(history).toEqual([
expect(history.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [
+23 -10
View File
@@ -7,6 +7,7 @@
import { type Part, type PartListUnion } from '@google/genai';
import { type ConversationRecord } from '../services/chatRecordingService.js';
import { partListUnionToString } from '../core/geminiRequest.js';
import { type HistoryTurn } from '../core/agentChatHistory.js';
/**
* Converts a PartListUnion into a normalized array of Part objects.
@@ -29,8 +30,8 @@ function ensurePartArray(content: PartListUnion): Part[] {
*/
export function convertSessionToClientHistory(
messages: ConversationRecord['messages'],
): Array<{ role: 'user' | 'model'; parts: Part[] }> {
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
): HistoryTurn[] {
const clientHistory: HistoryTurn[] = [];
for (const msg of messages) {
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
@@ -47,8 +48,11 @@ export function convertSessionToClientHistory(
}
clientHistory.push({
role: 'user',
parts: ensurePartArray(msg.content),
id: msg.id,
content: {
role: 'user',
parts: ensurePartArray(msg.content),
},
});
} else if (msg.type === 'gemini') {
const modelParts: Part[] = [];
@@ -85,8 +89,11 @@ export function convertSessionToClientHistory(
}
clientHistory.push({
role: 'model',
parts: modelParts,
id: msg.id,
content: {
role: 'model',
parts: modelParts,
},
});
const functionResponseParts: Part[] = [];
@@ -117,8 +124,11 @@ export function convertSessionToClientHistory(
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
id: `${msg.id}_response`,
content: {
role: 'user',
parts: functionResponseParts,
},
});
}
} else {
@@ -128,8 +138,11 @@ export function convertSessionToClientHistory(
if (modelParts.length > 0) {
clientHistory.push({
role: 'model',
parts: modelParts,
id: msg.id,
content: {
role: 'model',
parts: modelParts,
},
});
}
}