File size: 6,031 Bytes
ef6e870
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import { describe, it, expect } from 'vitest';
import { classifyContinueIntent } from '$lib/utils/agentic';
import { ContinueIntentKind, MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';

/**
 * Tests for the Continue button intent classifier.
 *
 * The classifier walks the persisted message history to decide which of three
 * resume paths a Continue click should take:
 *
 *   A. append_text  -> plain text assistant turn, resume with
 *                      continue_final_message.
 *   B. rerun_turn   -> assistant turn with tool_calls but no tool results yet,
 *                      the stream was cut mid turn and the tool_calls are
 *                      unrecoverable as a token level continuation. Drop the
 *                      target and rerun from the previous history.
 *   C. next_turn    -> assistant turn with tool_calls that were already
 *                      resolved by trailing tool results. Hand the history
 *                      back to the agentic loop so it starts the next turn.
 */

let nextId = 0;
function makeMsg(role: MessageRole, opts: Partial<DatabaseMessage> = {}): DatabaseMessage {
	nextId++;
	return {
		id: `msg-${nextId}`,
		convId: 'conv-1',
		type: MessageType.TEXT,
		timestamp: nextId,
		role,
		content: '',
		parent: null,
		children: [],
		...opts
	};
}

function toolCall(id: string, name: string, args: string = '{}'): string {
	return JSON.stringify([{ id, type: 'function', function: { name, arguments: args } }]);
}

describe('classifyContinueIntent', () => {
	it('returns append_text for a plain text assistant turn at the tail', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'hello' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'hi there' })
		];

		const intent = classifyContinueIntent(messages, 1);

		expect(intent).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});

	it('returns append_text for a plain text assistant turn in the middle', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'q1' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'a1' }),
			makeMsg(MessageRole.USER, { content: 'q2' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'a2' })
		];

		expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});

	it('returns rerun_turn when the assistant has tool_calls without results', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'list files' }),
			makeMsg(MessageRole.ASSISTANT, {
				content: '',
				toolCalls: toolCall('call_1', 'bash_tool', '{"command":"ls"}')
			})
		];

		const intent = classifyContinueIntent(messages, 1);

		expect(intent).toEqual({ kind: ContinueIntentKind.RERUN_TURN, truncateAfter: 0 });
	});

	it('returns next_turn when trailing tool results resolve the tool_calls', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'list files' }),
			makeMsg(MessageRole.ASSISTANT, {
				content: '',
				toolCalls: toolCall('call_1', 'bash_tool')
			}),
			makeMsg(MessageRole.TOOL, { content: 'file1\nfile2', toolCallId: 'call_1' })
		];

		const intent = classifyContinueIntent(messages, 1);

		expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
	});

	it('next_turn keeps all consecutive trailing tool results, not just one', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'do many things' }),
			makeMsg(MessageRole.ASSISTANT, {
				content: '',
				toolCalls: JSON.stringify([
					{ id: 'call_1', type: 'function', function: { name: 'a', arguments: '{}' } },
					{ id: 'call_2', type: 'function', function: { name: 'b', arguments: '{}' } }
				])
			}),
			makeMsg(MessageRole.TOOL, { content: 'r1', toolCallId: 'call_1' }),
			makeMsg(MessageRole.TOOL, { content: 'r2', toolCallId: 'call_2' })
		];

		const intent = classifyContinueIntent(messages, 1);

		expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 3 });
	});

	it('next_turn stops at the first non tool message after the target', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'go' }),
			makeMsg(MessageRole.ASSISTANT, {
				content: '',
				toolCalls: toolCall('call_1', 'a')
			}),
			makeMsg(MessageRole.TOOL, { content: 'r1', toolCallId: 'call_1' }),
			makeMsg(MessageRole.USER, { content: 'wait' }),
			makeMsg(MessageRole.TOOL, { content: 'late', toolCallId: 'call_1' })
		];

		const intent = classifyContinueIntent(messages, 1);

		// truncateAfter must point at the contiguous tool block, not jump over
		// the user message to grab the dangling late tool.
		expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
	});

	it('returns append_text when toolCalls is set but parses to empty array', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'q' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'a', toolCalls: '[]' })
		];

		expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});

	it('returns append_text when toolCalls is malformed JSON', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'q' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'a', toolCalls: '{not json' })
		];

		expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});

	it('returns append_text defensively when idx points at a non assistant message', () => {
		const messages = [
			makeMsg(MessageRole.USER, { content: 'q' }),
			makeMsg(MessageRole.ASSISTANT, { content: 'a' })
		];

		expect(classifyContinueIntent(messages, 0)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});

	it('returns append_text defensively when idx is out of bounds', () => {
		const messages = [makeMsg(MessageRole.ASSISTANT, { content: 'a' })];

		expect(classifyContinueIntent(messages, 5)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
		expect(classifyContinueIntent([], 0)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
	});
});