basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, expect, it } from 'vitest';8import type { Content } from '@google/genai';9import {10 buildSyntheticToolResponseParts,11 detectTurnInterruption,12 TURN_INTERRUPTION_HISTORY_TAIL_COUNT,13} from './turn-interruption.js';14 15const reminder = (text: string) => ({16 text: `<system-reminder>\n${text}\n</system-reminder>`,17});18 19describe('detectTurnInterruption', () => {20 it('uses a bounded history tail count for continuation detection callers', () => {21 expect(TURN_INTERRUPTION_HISTORY_TAIL_COUNT).toBe(50);22 });23 24 it('returns none for empty history', () => {25 expect(detectTurnInterruption([])).toEqual({ kind: 'none' });26 });27 28 it('returns none when the last turn is a clean model text response', () => {29 const history: Content[] = [30 { role: 'user', parts: [{ text: 'hello' }] },31 { role: 'model', parts: [{ text: 'hi there' }] },32 ];33 expect(detectTurnInterruption(history)).toEqual({ kind: 'none' });34 });35 36 it('returns none for a pure system-reminder user tail', () => {37 const history: Content[] = [38 { role: 'model', parts: [{ text: 'done' }] },39 { role: 'user', parts: [reminder('mcp tool added')] },40 ];41 expect(detectTurnInterruption(history)).toEqual({ kind: 'none' });42 });43 44 it('classifies a trailing user prompt as interrupted_prompt', () => {45 const history: Content[] = [46 { role: 'model', parts: [{ text: 'earlier answer' }] },47 { role: 'user', parts: [{ text: 'do the thing' }] },48 ];49 const result = detectTurnInterruption(history);50 expect(result).toEqual({51 kind: 'interrupted_prompt',52 parts: [{ text: 'do the thing' }],53 });54 });55 56 it('preserves per-turn reminder parts verbatim in the re-submission', () => {57 // The Retry send path does not re-inject per-turn reminders, so the58 // captured entry must keep them — the resumed request has to be59 // complete and belongs to the same logical turn.60 const history: Content[] = [61 {62 role: 'user',63 parts: [reminder('plan mode is on'), { text: 'real prompt' }],64 },65 ];66 const result = detectTurnInterruption(history);67 expect(result).toEqual({68 kind: 'interrupted_prompt',69 parts: [reminder('plan mode is on'), { text: 'real prompt' }],70 });71 });72 73 it('classifies a trailing tool_result submission as interrupted_prompt', () => {74 const frPart = {75 functionResponse: {76 id: 'call-1',77 name: 'read_file',78 response: { output: 'contents' },79 },80 };81 const history: Content[] = [82 {83 role: 'model',84 parts: [{ functionCall: { id: 'call-1', name: 'read_file' } }],85 },86 { role: 'user', parts: [frPart] },87 ];88 const result = detectTurnInterruption(history);89 expect(result).toEqual({ kind: 'interrupted_prompt', parts: [frPart] });90 });91 92 it('captures all consecutive trailing user entries with functionResponses first', () => {93 const history: Content[] = [94 { role: 'model', parts: [{ text: 'waiting on tool result' }] },95 { role: 'user', parts: [{ text: 'IDE context' }] },96 {97 role: 'user',98 parts: [99 {100 functionResponse: {101 id: 'call-1',102 name: 'read_file',103 response: { output: 'contents' },104 },105 },106 ],107 },108 ];109 110 const result = detectTurnInterruption(history);111 112 expect(result).toEqual({113 kind: 'interrupted_prompt',114 parts: [115 {116 functionResponse: {117 id: 'call-1',118 name: 'read_file',119 response: { output: 'contents' },120 },121 },122 { text: 'IDE context' },123 ],124 });125 });126 127 it('returns cloned parts that do not alias the history entry', () => {128 const history: Content[] = [129 { role: 'user', parts: [{ text: 'original' }] },130 ];131 const result = detectTurnInterruption(history);132 if (result.kind !== 'interrupted_prompt') {133 throw new Error(`expected interrupted_prompt, got ${result.kind}`);134 }135 result.parts[0]!.text = 'mutated';136 expect(history[0]!.parts![0]!.text).toBe('original');137 });138 139 it('classifies a dangling functionCall tail as interrupted_turn', () => {140 const history: Content[] = [141 { role: 'user', parts: [{ text: 'run the tool' }] },142 {143 role: 'model',144 parts: [145 { text: 'running…' },146 { functionCall: { id: 'call-1', name: 'shell' } },147 { functionCall: { id: 'call-2', name: 'read_file' } },148 ],149 },150 ];151 expect(detectTurnInterruption(history)).toEqual({152 kind: 'interrupted_turn',153 danglingCalls: [154 { callId: 'call-1', name: 'shell' },155 { callId: 'call-2', name: 'read_file' },156 ],157 });158 });159 160 it('ignores functionCalls without an id (unpairable on the wire)', () => {161 const history: Content[] = [162 {163 role: 'model',164 parts: [{ functionCall: { name: 'shell' } }],165 },166 ];167 expect(detectTurnInterruption(history)).toEqual({ kind: 'none' });168 });169 170 it('falls back to "unknown" for a dangling call without a name', () => {171 const history: Content[] = [172 { role: 'model', parts: [{ functionCall: { id: 'call-9' } }] },173 ];174 expect(detectTurnInterruption(history)).toEqual({175 kind: 'interrupted_turn',176 danglingCalls: [{ callId: 'call-9', name: 'unknown' }],177 });178 });179 180 it('ignores earlier dangling calls when the final entry is clean', () => {181 // The mid-history dangling call is covered by the defensive repair182 // passes in the send path, not by continue detection.183 const history: Content[] = [184 {185 role: 'model',186 parts: [{ functionCall: { id: 'old-call', name: 'shell' } }],187 },188 { role: 'user', parts: [{ text: 'never mind' }] },189 { role: 'model', parts: [{ text: 'ok' }] },190 ];191 expect(detectTurnInterruption(history)).toEqual({ kind: 'none' });192 });193 194 it('returns none for a user tail with no parts', () => {195 const history: Content[] = [{ role: 'user', parts: [] }];196 expect(detectTurnInterruption(history)).toEqual({ kind: 'none' });197 });198});199 200describe('buildSyntheticToolResponseParts', () => {201 it('builds one error functionResponse per dangling call, matching repair shape', () => {202 const parts = buildSyntheticToolResponseParts(203 [204 { callId: 'call-1', name: 'shell' },205 { callId: 'call-2', name: 'read_file' },206 ],207 'interrupted',208 );209 expect(parts).toEqual([210 {211 functionResponse: {212 id: 'call-1',213 name: 'shell',214 response: { error: 'interrupted' },215 },216 },217 {218 functionResponse: {219 id: 'call-2',220 name: 'read_file',221 response: { error: 'interrupted' },222 },223 },224 ]);225 });226});227 