repoburg 1.3.199 → 1.3.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-pane/backend/data.db +0 -0
- package/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +1658 -277
- package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
- package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
- package/agent-pane/backend/src/db/index.ts +12 -0
- package/agent-pane/backend/src/index.ts +5 -2
- package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
- package/agent-pane/backend/src/providers/index.ts +3 -1
- package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
- package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
- package/agent-pane/backend/src/repoburg/action-mapping.ts +294 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +285 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +356 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.ts +261 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +180 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.ts +194 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +780 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.ts +471 -0
- package/agent-pane/backend/src/repoburg/turn-projector.test.ts +621 -0
- package/agent-pane/backend/src/repoburg/turn-projector.ts +637 -0
- package/agent-pane/backend/src/routes/providers.test.ts +18 -1
- package/agent-pane/backend/src/routes/providers.ts +19 -1
- package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
- package/agent-pane/backend/src/routes/sessions.ts +82 -9
- package/agent-pane/backend/src/routes/threads.ts +21 -6
- package/agent-pane/backend/src/routes/turns.test.ts +45 -1
- package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
- package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-r-xwfOKQ.css} +1 -1
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
- package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
- package/agent-pane/frontend/src/api/websocket.ts +3 -1
- package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
- package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
- package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
- package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
- package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
- package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
- package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
- package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
- package/agent-pane/frontend/src/constants/models.ts +37 -0
- package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
- package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
- package/agent-pane/frontend/src/store/store.test.ts +54 -0
- package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
- package/agent-pane/frontend/src/styles/app.css +26 -1
- package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
- package/agent-pane/frontend/src/test/fixtures.ts +1 -0
- package/agent-pane/frontend/src/types/index.ts +2 -0
- package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
- package/agent-pane/shared/types.ts +17 -0
- package/agent-pane-bug-repro-no-agent-reply.png +0 -0
- package/askuser-after-step5-postgres-selected.png +0 -0
- package/askuser-after-step7-answer-sent.png +0 -0
- package/askuser-card-visible.png +0 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +36 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +199 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +92 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
- package/backend/dist/src/app.controller.d.ts +1 -0
- package/backend/dist/src/app.module.js +4 -0
- package/backend/dist/src/app.module.js.map +1 -1
- package/backend/dist/src/app.service.d.ts +1 -0
- package/backend/dist/src/app.service.js +4 -1
- package/backend/dist/src/app.service.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.module.js +4 -0
- package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.service.d.ts +7 -1
- package/backend/dist/src/interactive-chat/chat.service.js +54 -7
- package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +37 -2
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
- package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
- package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
- package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
- package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
- package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
- package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
- package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
- package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- package/daemon/dist/api/services.js +4 -0
- package/daemon/dist/index.js +0 -0
- package/daemon/dist/serviceManager.js +30 -11
- package/daemon/dist/serviceManager.spec.js +26 -0
- package/daemon/dist/workspace-validation.js +63 -0
- package/daemon/dist/workspace-validation.spec.js +63 -0
- package/package.json +1 -1
- package/r2-step4-apple-banana.png +0 -0
- package/r2-step5-after-rewind-apple-last.png +0 -0
- package/r2-step7-apple-cherry.png +0 -0
- package/r2-step8-cherry-menu-no-restore.png +0 -0
- package/r3-step3-live-task-card-running.png +0 -0
- package/r3-step4-after-completion.png +0 -0
- package/r3-step5-persistence-after-reload.png +0 -0
- package/r3-step6-tasks-tab-card.png +0 -0
- package/reverify-answered-state-answer-sent.png +0 -0
- package/reverify-card-visible-after-completion.png +0 -0
- package/rewind-step3-apple-banana-turns.png +0 -0
- package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
- package/rewind-step6-apple-menu-no-restore.png +0 -0
- package/rewind-step7-final-turnlist-unchanged.png +0 -0
- package/rewind-step7-negative-error-toast.png +0 -0
- package/rewind-step7-negative-error-toast2.png +0 -0
- package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
- package/subagent-r2-step6-tasks-panel-card.png +0 -0
- package/subagent-step3-tasks-tab-done-card.png +0 -0
- package/subagent-step4-turn-stuck-no-cards.png +0 -0
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock the HTTP client class — the provider must only talk to the interface.
|
|
4
|
+
vi.mock('./repoburg-client.ts', () => ({
|
|
5
|
+
RepoburgHttpClient: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock the daemon resolver — lifecycle logic has its own test file.
|
|
9
|
+
vi.mock('./backend-resolver.ts', () => ({
|
|
10
|
+
resolveBackendUrl: vi.fn(),
|
|
11
|
+
invalidateBackend: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Capture SSE stream invocations — deliver agent-runtime messages manually
|
|
15
|
+
// and resolve the stream promise to simulate stream end / reconnect.
|
|
16
|
+
interface StreamCall {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
sessionId: string;
|
|
19
|
+
onMessage: (message: AgentRuntimeMessage) => void;
|
|
20
|
+
opts: { signal?: AbortSignal; onActivity?: () => void };
|
|
21
|
+
resolve: (reason: string) => void;
|
|
22
|
+
}
|
|
23
|
+
const streamCalls: StreamCall[] = [];
|
|
24
|
+
vi.mock('./agent-runtime-client.ts', () => ({
|
|
25
|
+
streamAgentRuntimeMessages: vi.fn(
|
|
26
|
+
(baseUrl: string, sessionId: string, onMessage: (m: AgentRuntimeMessage) => void, opts: StreamCall['opts']) =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
streamCalls.push({ baseUrl, sessionId, onMessage, opts, resolve });
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { RepoburgHttpClient } from './repoburg-client.ts';
|
|
34
|
+
import {
|
|
35
|
+
resolveBackendUrl,
|
|
36
|
+
invalidateBackend as invalidateBackendImport,
|
|
37
|
+
} from './backend-resolver.ts';
|
|
38
|
+
import { streamAgentRuntimeMessages } from './agent-runtime-client.ts';
|
|
39
|
+
import type { AgentRuntimeMessage } from './agent-runtime-client.ts';
|
|
40
|
+
import type { RepoburgRuntimeClient } from './repoburg-client.ts';
|
|
41
|
+
|
|
42
|
+
// Capture broadcasts without touching a real WebSocket server.
|
|
43
|
+
const captured: Array<{ event: string; data: unknown }> = [];
|
|
44
|
+
vi.mock('../ws/index.ts', async (importOriginal) => {
|
|
45
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
46
|
+
return {
|
|
47
|
+
...actual,
|
|
48
|
+
broadcast: vi.fn((event: string, data: unknown) => {
|
|
49
|
+
captured.push({ event, data });
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Import after mocks are set up
|
|
55
|
+
import { repoburgProvider, mapReasoningEffort } from './repoburg-provider.ts';
|
|
56
|
+
import { initDb } from '../db/index.ts';
|
|
57
|
+
import type Database from 'better-sqlite3';
|
|
58
|
+
import type { Session, Turn } from '../db/schema.ts';
|
|
59
|
+
|
|
60
|
+
/** Build a mock runtime client (HTTP control surface only). */
|
|
61
|
+
function makeMockClient(): { client: RepoburgRuntimeClient } {
|
|
62
|
+
const client = {
|
|
63
|
+
createSession: vi.fn(),
|
|
64
|
+
patchSession: vi.fn(),
|
|
65
|
+
getSession: vi.fn(),
|
|
66
|
+
createInput: vi.fn(),
|
|
67
|
+
getInputWithActions: vi.fn(),
|
|
68
|
+
getSessionInputs: vi.fn(),
|
|
69
|
+
deleteInputsUntilHere: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
cancel: vi.fn().mockResolvedValue({ success: true, cancelled: true }),
|
|
71
|
+
getModels: vi.fn(),
|
|
72
|
+
} as unknown as RepoburgRuntimeClient;
|
|
73
|
+
return { client };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let mock = makeMockClient();
|
|
77
|
+
|
|
78
|
+
/** Deliver an agent-runtime message through the active stream. */
|
|
79
|
+
function deliver(message: AgentRuntimeMessage): void {
|
|
80
|
+
const call = streamCalls[streamCalls.length - 1];
|
|
81
|
+
call.onMessage(message);
|
|
82
|
+
call.opts.onActivity?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('repoburgProvider', () => {
|
|
86
|
+
let db: Database.Database;
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
await repoburgProvider.shutdown(); // reset module state + session map
|
|
91
|
+
mock = makeMockClient();
|
|
92
|
+
streamCalls.length = 0;
|
|
93
|
+
vi.mocked(RepoburgHttpClient).mockImplementation(() => mock.client as never);
|
|
94
|
+
vi.mocked(resolveBackendUrl)
|
|
95
|
+
.mockReset()
|
|
96
|
+
.mockResolvedValue({ baseUrl: 'http://repoburg.test', port: 1234, name: 'test' });
|
|
97
|
+
captured.length = 0;
|
|
98
|
+
|
|
99
|
+
db = initDb(':memory:');
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
db.prepare(
|
|
102
|
+
`INSERT INTO sessions (id, title, runtime_type, model, model_reasoning_effort, status, is_running, lifecycle, created_at, updated_at)
|
|
103
|
+
VALUES ('s1', NULL, 'repoburg', 'ollama/glm-5.3:cloud', 'medium', 'active', 0, 'working', ?, ?)`,
|
|
104
|
+
).run(now, now);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
db.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Insert a pending turn and load session + turn rows for startTurn. */
|
|
112
|
+
function seedTurn(turnId: string, prompt = 'hello'): { session: Session; turn: Turn } {
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
db.prepare(
|
|
115
|
+
`INSERT INTO turns (id, session_id, user_prompt, status, created_at, updated_at)
|
|
116
|
+
VALUES (?, 's1', ?, 'pending', ?, ?)`,
|
|
117
|
+
).run(turnId, prompt, now, now);
|
|
118
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as Session;
|
|
119
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn;
|
|
120
|
+
return { session, turn };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Start a seeded turn with a successfully created runtime session. */
|
|
124
|
+
async function startedTurn(turnId = 't1'): Promise<void> {
|
|
125
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
126
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
127
|
+
const { session, turn } = seedTurn(turnId);
|
|
128
|
+
await repoburgProvider.startTurn(session, turn);
|
|
129
|
+
await vi.waitFor(() => expect(streamCalls.length).toBeGreaterThanOrEqual(1));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it('implements all RuntimeProvider methods', () => {
|
|
133
|
+
expect(repoburgProvider.startTurn).toBeTypeOf('function');
|
|
134
|
+
expect(repoburgProvider.interruptTurn).toBeTypeOf('function');
|
|
135
|
+
expect(repoburgProvider.clearThread).toBeTypeOf('function');
|
|
136
|
+
expect(repoburgProvider.clearAllThreads).toBeTypeOf('function');
|
|
137
|
+
expect(repoburgProvider.getModels).toBeTypeOf('function');
|
|
138
|
+
expect(repoburgProvider.shutdown).toBeTypeOf('function');
|
|
139
|
+
});
|
|
140
|
+
it('implements all RuntimeProvider methods', () => {
|
|
141
|
+
expect(repoburgProvider.startTurn).toBeTypeOf('function');
|
|
142
|
+
expect(repoburgProvider.interruptTurn).toBeTypeOf('function');
|
|
143
|
+
expect(repoburgProvider.clearThread).toBeTypeOf('function');
|
|
144
|
+
expect(repoburgProvider.clearAllThreads).toBeTypeOf('function');
|
|
145
|
+
expect(repoburgProvider.getModels).toBeTypeOf('function');
|
|
146
|
+
expect(repoburgProvider.rewindRuntime).toBeTypeOf('function');
|
|
147
|
+
expect(repoburgProvider.shutdown).toBeTypeOf('function');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rewindRuntime truncates via until-here with the LAST runtime input id', async () => {
|
|
151
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
152
|
+
const { session } = seedTurn('t1');
|
|
153
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
154
|
+
JSON.stringify(['in-1', 'in-2', 'in-3']),
|
|
155
|
+
't1',
|
|
156
|
+
);
|
|
157
|
+
// Re-load the turn row AFTER the update (seedTurn fetched a stale row).
|
|
158
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('t1') as Turn;
|
|
159
|
+
|
|
160
|
+
await repoburgProvider.rewindRuntime!(session, turn);
|
|
161
|
+
|
|
162
|
+
expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-3');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rewindRuntime throws when the turn has no runtime input ids', async () => {
|
|
166
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
167
|
+
const { session, turn } = seedTurn('t1');
|
|
168
|
+
|
|
169
|
+
await expect(repoburgProvider.rewindRuntime!(session, turn)).rejects.toThrow(
|
|
170
|
+
'no runtime input ids',
|
|
171
|
+
);
|
|
172
|
+
expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rewindRuntime is a no-op when the session has no runtime thread', async () => {
|
|
176
|
+
const { session, turn } = seedTurn('t1');
|
|
177
|
+
|
|
178
|
+
await repoburgProvider.rewindRuntime!(session, turn);
|
|
179
|
+
|
|
180
|
+
expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rewindRuntime accepts the typed Turn array form of runtime_input_ids', async () => {
|
|
184
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
185
|
+
const { session, turn } = seedTurn('t1');
|
|
186
|
+
const typedTurn = { ...turn, runtime_input_ids: ['in-a', 'in-b'] } as Turn;
|
|
187
|
+
|
|
188
|
+
await repoburgProvider.rewindRuntime!(session, typedTurn);
|
|
189
|
+
|
|
190
|
+
expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-b');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('mapReasoningEffort', () => {
|
|
194
|
+
it('passes valid Ollama levels through and clamps xhigh to high', () => {
|
|
195
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'low')).toBe('low');
|
|
196
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'medium')).toBe('medium');
|
|
197
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'high')).toBe('high');
|
|
198
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'xhigh')).toBe('high');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('omits Ollama-inexpressible levels (minimal/none) and unknown values', () => {
|
|
202
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'minimal')).toBeUndefined();
|
|
203
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'none')).toBeUndefined();
|
|
204
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', 'ultra')).toBeUndefined();
|
|
205
|
+
expect(mapReasoningEffort('ollama/glm-5.3:cloud', null)).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('passes the full root enum through for non-Ollama providers', () => {
|
|
209
|
+
expect(mapReasoningEffort('zai/glm-5', 'xhigh')).toBe('xhigh');
|
|
210
|
+
expect(mapReasoningEffort('zai/glm-5', 'minimal')).toBe('minimal');
|
|
211
|
+
expect(mapReasoningEffort(null, 'high')).toBe('high');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('getModels maps runtime models to ModelInfo', async () => {
|
|
216
|
+
mock.client.getModels = vi.fn().mockResolvedValue([
|
|
217
|
+
{ id: 'ollama/glm-5.3:cloud', name: 'glm-5.3:cloud' },
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const models = await repoburgProvider.getModels();
|
|
221
|
+
expect(models).toHaveLength(1);
|
|
222
|
+
expect(models[0].slug).toBe('ollama/glm-5.3:cloud');
|
|
223
|
+
expect(models[0].displayName).toBe('glm-5.3:cloud');
|
|
224
|
+
expect(models[0].reasoningLevels.length).toBeGreaterThan(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('getModels offers 3 levels for Ollama models, 4 for other providers', async () => {
|
|
228
|
+
mock.client.getModels = vi.fn().mockResolvedValue([
|
|
229
|
+
{ id: 'ollama/glm-5.3:cloud', name: 'glm-5.3:cloud' },
|
|
230
|
+
{ id: 'zai/glm-5', name: 'glm-5' },
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
const models = await repoburgProvider.getModels();
|
|
234
|
+
expect(models[0].reasoningLevels.map((l) => l.effort)).toEqual(['low', 'medium', 'high']);
|
|
235
|
+
expect(models[1].reasoningLevels.map((l) => l.effort)).toEqual([
|
|
236
|
+
'low',
|
|
237
|
+
'medium',
|
|
238
|
+
'high',
|
|
239
|
+
'xhigh',
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('getModels never auto-starts a backend (autoStart=false)', async () => {
|
|
244
|
+
mock.client.getModels = vi.fn().mockResolvedValue([]);
|
|
245
|
+
await repoburgProvider.getModels();
|
|
246
|
+
expect(resolveBackendUrl).toHaveBeenCalledWith(process.cwd(), { autoStart: false });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('getModels returns empty array when no backend is running', async () => {
|
|
250
|
+
vi.mocked(resolveBackendUrl).mockResolvedValue(null);
|
|
251
|
+
const models = await repoburgProvider.getModels();
|
|
252
|
+
expect(models).toEqual([]);
|
|
253
|
+
expect(mock.client.getModels).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('getModels returns empty array on error', async () => {
|
|
257
|
+
mock.client.getModels = vi.fn().mockRejectedValue(new Error('backend down'));
|
|
258
|
+
|
|
259
|
+
const models = await repoburgProvider.getModels();
|
|
260
|
+
expect(models).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('startTurn resolves the workspace backend before creating the session', async () => {
|
|
264
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
265
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
266
|
+
db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/tmp/proj', 's1');
|
|
267
|
+
|
|
268
|
+
const { session, turn } = seedTurn('t1');
|
|
269
|
+
await repoburgProvider.startTurn(session, turn);
|
|
270
|
+
|
|
271
|
+
expect(resolveBackendUrl).toHaveBeenCalledWith('/tmp/proj');
|
|
272
|
+
expect(mock.client.createSession).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('startTurn fails the turn when backend resolution fails (daemon down)', async () => {
|
|
276
|
+
vi.mocked(resolveBackendUrl).mockRejectedValue(
|
|
277
|
+
new Error(
|
|
278
|
+
"Repoburg daemon not reachable at http://localhost:9998. Start it with 'repoburg-daemon'.",
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const { session, turn } = seedTurn('t1');
|
|
283
|
+
await repoburgProvider.startTurn(session, turn);
|
|
284
|
+
|
|
285
|
+
const failedTurn = db.prepare('SELECT status, error FROM turns WHERE id = ?').get('t1') as
|
|
286
|
+
| { status: string; error: string | null }
|
|
287
|
+
| undefined;
|
|
288
|
+
expect(failedTurn?.status).toBe('failed');
|
|
289
|
+
expect(failedTurn?.error).toMatch(/daemon not reachable/);
|
|
290
|
+
|
|
291
|
+
const failed = captured.find((b) => b.event === 'turn:failed');
|
|
292
|
+
expect(failed).toBeDefined();
|
|
293
|
+
expect(mock.client.createSession).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('startTurn invalidates the resolver cache when the backend connection drops', async () => {
|
|
297
|
+
mock.client.createSession = vi.fn().mockRejectedValue(new Error('fetch failed ECONNREFUSED'));
|
|
298
|
+
|
|
299
|
+
const { session, turn } = seedTurn('t1');
|
|
300
|
+
await repoburgProvider.startTurn(session, turn);
|
|
301
|
+
|
|
302
|
+
expect(vi.mocked(invalidateBackendImport)).toHaveBeenCalledWith('http://repoburg.test');
|
|
303
|
+
const failedTurn = db.prepare('SELECT status FROM turns WHERE id = ?').get('t1') as
|
|
304
|
+
| { status: string }
|
|
305
|
+
| undefined;
|
|
306
|
+
expect(failedTurn?.status).toBe('failed');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('startTurn invalidates the resolver cache when createInput drops mid-turn', async () => {
|
|
310
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
311
|
+
mock.client.createInput = vi.fn().mockRejectedValue(new Error('fetch failed ECONNREFUSED'));
|
|
312
|
+
|
|
313
|
+
const { session, turn } = seedTurn('t1');
|
|
314
|
+
await repoburgProvider.startTurn(session, turn);
|
|
315
|
+
|
|
316
|
+
expect(vi.mocked(invalidateBackendImport)).toHaveBeenCalledWith('http://repoburg.test');
|
|
317
|
+
const failedTurn = db.prepare('SELECT status FROM turns WHERE id = ?').get('t1') as
|
|
318
|
+
| { status: string }
|
|
319
|
+
| undefined;
|
|
320
|
+
expect(failedTurn?.status).toBe('failed');
|
|
321
|
+
expect(captured.find((b) => b.event === 'turn:failed')).toBeDefined();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('startTurn creates a runtime session, persists external_thread_id, and starts the input + SSE stream', async () => {
|
|
325
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
326
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
327
|
+
|
|
328
|
+
const { session, turn } = seedTurn('t1');
|
|
329
|
+
await repoburgProvider.startTurn(session, turn);
|
|
330
|
+
await vi.waitFor(() => expect(streamCalls.length).toBeGreaterThanOrEqual(1));
|
|
331
|
+
|
|
332
|
+
// Runtime session created with the agent-pane model + reasoning config.
|
|
333
|
+
expect(mock.client.createSession).toHaveBeenCalledWith({
|
|
334
|
+
model_id: 'ollama/glm-5.3:cloud',
|
|
335
|
+
reasoning_effort: 'medium',
|
|
336
|
+
});
|
|
337
|
+
// Repoburg session id persisted as external_thread_id.
|
|
338
|
+
const row = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
|
|
339
|
+
| { external_thread_id: string | null }
|
|
340
|
+
| undefined;
|
|
341
|
+
expect(row?.external_thread_id).toBe('rs-1');
|
|
342
|
+
|
|
343
|
+
// Turn lifecycle started.
|
|
344
|
+
expect(captured.find((b) => b.event === 'turn:started')).toBeDefined();
|
|
345
|
+
expect(db.prepare('SELECT status FROM turns WHERE id = ?').get('t1')).toEqual({
|
|
346
|
+
status: 'running',
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// SSE message stream opened against the runtime session.
|
|
350
|
+
expect(streamCalls[0].baseUrl).toBe('http://repoburg.test');
|
|
351
|
+
expect(streamCalls[0].sessionId).toBe('rs-1');
|
|
352
|
+
expect(streamCalls[0].opts.signal).toBeInstanceOf(AbortSignal);
|
|
353
|
+
|
|
354
|
+
// Input started with the autonomous execution strategy.
|
|
355
|
+
expect(mock.client.createInput).toHaveBeenCalledWith('rs-1', {
|
|
356
|
+
user_prompt: 'hello',
|
|
357
|
+
execution_strategy: 'auto_apply',
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('startTurn clamps Ollama xhigh to high when creating the session', async () => {
|
|
362
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
363
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
364
|
+
db.prepare('UPDATE sessions SET model_reasoning_effort = ? WHERE id = ?').run('xhigh', 's1');
|
|
365
|
+
|
|
366
|
+
const { session, turn } = seedTurn('t1');
|
|
367
|
+
await repoburgProvider.startTurn(session, turn);
|
|
368
|
+
|
|
369
|
+
expect(mock.client.createSession).toHaveBeenCalledWith({
|
|
370
|
+
model_id: 'ollama/glm-5.3:cloud',
|
|
371
|
+
reasoning_effort: 'high', // xhigh is invalid for Ollama `think`
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('startTurn pushes a patch when only the reasoning effort changes', async () => {
|
|
376
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
377
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
378
|
+
|
|
379
|
+
// First turn — model ollama/a, effort medium.
|
|
380
|
+
db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run('ollama/a', 's1');
|
|
381
|
+
const first = seedTurn('t1');
|
|
382
|
+
await repoburgProvider.startTurn(first.session, first.turn);
|
|
383
|
+
expect(mock.client.patchSession).not.toHaveBeenCalled();
|
|
384
|
+
|
|
385
|
+
// Same model, different effort — patch must still fire.
|
|
386
|
+
db.prepare('UPDATE sessions SET model_reasoning_effort = ? WHERE id = ?').run('high', 's1');
|
|
387
|
+
const secondSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as Session;
|
|
388
|
+
const second = seedTurn('t2');
|
|
389
|
+
await repoburgProvider.startTurn(secondSession, second.turn);
|
|
390
|
+
|
|
391
|
+
expect(mock.client.patchSession).toHaveBeenCalledWith('rs-1', {
|
|
392
|
+
model_id: 'ollama/a',
|
|
393
|
+
reasoning_effort: 'high',
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('startTurn re-pushes the clamped effort on resumed sessions (self-heal)', async () => {
|
|
398
|
+
// Pre-fix session: runtime session already exists (external_thread_id) with
|
|
399
|
+
// an unsafe xhigh effort — the first new turn must PATCH the clamped value.
|
|
400
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
401
|
+
db.prepare(
|
|
402
|
+
'UPDATE sessions SET external_thread_id = ?, model_reasoning_effort = ? WHERE id = ?',
|
|
403
|
+
).run('rs-1', 'xhigh', 's1');
|
|
404
|
+
|
|
405
|
+
const { session, turn } = seedTurn('t1');
|
|
406
|
+
await repoburgProvider.startTurn(session, turn);
|
|
407
|
+
|
|
408
|
+
expect(mock.client.patchSession).toHaveBeenCalledWith('rs-1', {
|
|
409
|
+
model_id: 'ollama/glm-5.3:cloud',
|
|
410
|
+
reasoning_effort: 'high',
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('startTurn pushes mid-session model changes to the runtime session', async () => {
|
|
415
|
+
mock.client.createSession = vi.fn().mockResolvedValue({ id: 'rs-1' });
|
|
416
|
+
mock.client.createInput = vi.fn().mockResolvedValue({ id: 'in-1' });
|
|
417
|
+
|
|
418
|
+
// First turn — session created, model recorded as 'ollama/a'.
|
|
419
|
+
db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run('ollama/a', 's1');
|
|
420
|
+
const first = seedTurn('t1');
|
|
421
|
+
await repoburgProvider.startTurn(first.session, first.turn);
|
|
422
|
+
expect(mock.client.patchSession).not.toHaveBeenCalled();
|
|
423
|
+
|
|
424
|
+
// Model changed in agent-pane — second turn pushes the change.
|
|
425
|
+
db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run('ollama/b', 's1');
|
|
426
|
+
const secondSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as Session;
|
|
427
|
+
const second = seedTurn('t2');
|
|
428
|
+
await repoburgProvider.startTurn(secondSession, second.turn);
|
|
429
|
+
|
|
430
|
+
expect(mock.client.patchSession).toHaveBeenCalledWith('rs-1', {
|
|
431
|
+
model_id: 'ollama/b',
|
|
432
|
+
reasoning_effort: 'medium',
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('full turn: live deltas, assistant, tool results, result → completed turn with usage and title', async () => {
|
|
437
|
+
mock.client.getSession = vi.fn().mockResolvedValue({
|
|
438
|
+
id: 'rs-1',
|
|
439
|
+
session_title: 'Auto Title',
|
|
440
|
+
is_running: false,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await startedTurn('t1');
|
|
444
|
+
|
|
445
|
+
// Live text delta (starts the agent_message item)…
|
|
446
|
+
deliver({ type: 'stream_event', sessionId: 'rs-1', inputId: 'in-1', delta: { type: 'text', text: 'Hello' } });
|
|
447
|
+
const started = captured.find((b) => b.event === 'item:started');
|
|
448
|
+
expect(started).toBeDefined();
|
|
449
|
+
expect((started!.data as { item: { type: string } }).item.type).toBe('agent_message');
|
|
450
|
+
|
|
451
|
+
// …then a delta…
|
|
452
|
+
deliver({ type: 'stream_event', sessionId: 'rs-1', inputId: 'in-1', delta: { type: 'text', text: ' world' } });
|
|
453
|
+
const delta = captured.find((b) => b.event === 'item:delta');
|
|
454
|
+
expect(delta).toBeDefined();
|
|
455
|
+
expect((delta!.data as { itemId: string; delta: string }).itemId).toBe('in-1-text');
|
|
456
|
+
expect((delta!.data as { itemId: string; delta: string }).delta).toBe(' world');
|
|
457
|
+
|
|
458
|
+
// …and a thinking delta (starts the reasoning item).
|
|
459
|
+
deliver({ type: 'stream_event', sessionId: 'rs-1', inputId: 'in-1', delta: { type: 'thinking', text: 'thinking about it' } });
|
|
460
|
+
expect(
|
|
461
|
+
captured
|
|
462
|
+
.filter((b) => b.event === 'item:started')
|
|
463
|
+
.some((b) => (b.data as { item: { type: string } }).item.type === 'reasoning'),
|
|
464
|
+
).toBe(true);
|
|
465
|
+
|
|
466
|
+
// Round ends — assistant completes the streamed items.
|
|
467
|
+
deliver({
|
|
468
|
+
type: 'assistant',
|
|
469
|
+
sessionId: 'rs-1',
|
|
470
|
+
inputId: 'in-1',
|
|
471
|
+
content: [
|
|
472
|
+
{ type: 'thinking', thinking: 'thinking about it' },
|
|
473
|
+
{ type: 'text', text: 'Hello world' },
|
|
474
|
+
],
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Executed actions become items (one tool_result each).
|
|
478
|
+
deliver({
|
|
479
|
+
type: 'user',
|
|
480
|
+
sessionId: 'rs-1',
|
|
481
|
+
inputId: 'in-1',
|
|
482
|
+
content: [
|
|
483
|
+
{
|
|
484
|
+
type: 'tool_result',
|
|
485
|
+
tool_use_id: 'a1',
|
|
486
|
+
content: 'created',
|
|
487
|
+
is_error: false,
|
|
488
|
+
action: {
|
|
489
|
+
id: 'a1',
|
|
490
|
+
action_type: 'create_file',
|
|
491
|
+
file_path: 'src/a.ts',
|
|
492
|
+
command_string: null,
|
|
493
|
+
status: 'CONFIRMED_KEPT',
|
|
494
|
+
server_name: null,
|
|
495
|
+
tool_name: null,
|
|
496
|
+
arguments: null,
|
|
497
|
+
plain: null,
|
|
498
|
+
selections: null,
|
|
499
|
+
order_of_execution: 0,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
type: 'tool_result',
|
|
504
|
+
tool_use_id: 'a2',
|
|
505
|
+
content: 'a.ts',
|
|
506
|
+
is_error: false,
|
|
507
|
+
action: {
|
|
508
|
+
id: 'a2',
|
|
509
|
+
action_type: 'run_command',
|
|
510
|
+
file_path: null,
|
|
511
|
+
command_string: 'ls',
|
|
512
|
+
status: 'CONFIRMED_KEPT',
|
|
513
|
+
server_name: null,
|
|
514
|
+
tool_name: null,
|
|
515
|
+
arguments: null,
|
|
516
|
+
plain: null,
|
|
517
|
+
selections: null,
|
|
518
|
+
order_of_execution: 1,
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
});
|
|
523
|
+
expect(
|
|
524
|
+
captured.some(
|
|
525
|
+
(b) => b.event === 'item:completed' && (b.data as { item: { type: string } }).item.type === 'file_change',
|
|
526
|
+
),
|
|
527
|
+
).toBe(true);
|
|
528
|
+
|
|
529
|
+
const items = db.prepare('SELECT type, status FROM items WHERE turn_id = ? ORDER BY rowid').all('t1') as Array<{
|
|
530
|
+
type: string;
|
|
531
|
+
status: string;
|
|
532
|
+
}>;
|
|
533
|
+
expect(items.map((i) => i.type)).toEqual([
|
|
534
|
+
'agent_message',
|
|
535
|
+
'reasoning',
|
|
536
|
+
'file_change',
|
|
537
|
+
'command_execution',
|
|
538
|
+
]);
|
|
539
|
+
expect(items.every((i) => i.status === 'completed')).toBe(true);
|
|
540
|
+
|
|
541
|
+
// Terminal result — turn completes with result text + accumulated usage.
|
|
542
|
+
deliver({
|
|
543
|
+
type: 'result',
|
|
544
|
+
subtype: 'success',
|
|
545
|
+
sessionId: 'rs-1',
|
|
546
|
+
is_error: false,
|
|
547
|
+
result: 'Hello world',
|
|
548
|
+
usage: {
|
|
549
|
+
input_tokens: 100,
|
|
550
|
+
output_tokens: 20,
|
|
551
|
+
cache_read_input_tokens: 5,
|
|
552
|
+
cache_creation_input_tokens: 0,
|
|
553
|
+
},
|
|
554
|
+
num_rounds: 1,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const completedTurn = db.prepare(
|
|
558
|
+
'SELECT status, agent_response, token_usage FROM turns WHERE id = ?',
|
|
559
|
+
).get('t1') as { status: string; agent_response: string | null; token_usage: string | null } | undefined;
|
|
560
|
+
expect(completedTurn?.status).toBe('completed');
|
|
561
|
+
expect(completedTurn?.agent_response).toBe('Hello world');
|
|
562
|
+
expect(JSON.parse(completedTurn!.token_usage!)).toMatchObject({
|
|
563
|
+
input_tokens: 100,
|
|
564
|
+
output_tokens: 20,
|
|
565
|
+
cached_input_tokens: 5,
|
|
566
|
+
});
|
|
567
|
+
expect(captured.find((b) => b.event === 'turn:completed')).toBeDefined();
|
|
568
|
+
|
|
569
|
+
// Title synced from the runtime session (async — fire-and-forget).
|
|
570
|
+
await vi.waitFor(() => {
|
|
571
|
+
const sess = db.prepare('SELECT title, has_unreviewed FROM sessions WHERE id = ?').get('s1') as
|
|
572
|
+
| { title: string | null; has_unreviewed: number }
|
|
573
|
+
| undefined;
|
|
574
|
+
expect(sess?.title).toBe('Auto Title');
|
|
575
|
+
expect(sess?.has_unreviewed).toBe(1);
|
|
576
|
+
});
|
|
577
|
+
expect(captured.find((b) => b.event === 'session:title')).toBeDefined();
|
|
578
|
+
|
|
579
|
+
// Stream ends — no reconnect after the terminal result.
|
|
580
|
+
streamCalls[0].resolve('result');
|
|
581
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
582
|
+
expect(streamCalls.length).toBe(1);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('result error_during_execution fails the turn and closes open items as failed', async () => {
|
|
586
|
+
await startedTurn('t1');
|
|
587
|
+
|
|
588
|
+
deliver({ type: 'stream_event', sessionId: 'rs-1', inputId: 'in-1', delta: { type: 'text', text: 'partial' } });
|
|
589
|
+
deliver({
|
|
590
|
+
type: 'result',
|
|
591
|
+
subtype: 'error_during_execution',
|
|
592
|
+
sessionId: 'rs-1',
|
|
593
|
+
is_error: true,
|
|
594
|
+
result: '',
|
|
595
|
+
num_rounds: 1,
|
|
596
|
+
errors: ['boom'],
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const failedTurn = db.prepare('SELECT status, error FROM turns WHERE id = ?').get('t1') as
|
|
600
|
+
| { status: string; error: string | null }
|
|
601
|
+
| undefined;
|
|
602
|
+
expect(failedTurn?.status).toBe('failed');
|
|
603
|
+
expect(failedTurn?.error).toBe('boom');
|
|
604
|
+
|
|
605
|
+
// The in_progress streamed item is closed as failed.
|
|
606
|
+
const items = db.prepare('SELECT status FROM items WHERE turn_id = ?').all('t1') as Array<{
|
|
607
|
+
status: string;
|
|
608
|
+
}>;
|
|
609
|
+
expect(items.length).toBe(1);
|
|
610
|
+
expect(items[0].status).toBe('failed');
|
|
611
|
+
|
|
612
|
+
const failed = captured.find((b) => b.event === 'turn:failed');
|
|
613
|
+
expect(failed).toBeDefined();
|
|
614
|
+
expect((failed!.data as { error: string }).error).toBe('boom');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('result aborted aborts the turn', async () => {
|
|
618
|
+
await startedTurn('t1');
|
|
619
|
+
|
|
620
|
+
deliver({
|
|
621
|
+
type: 'result',
|
|
622
|
+
subtype: 'aborted',
|
|
623
|
+
sessionId: 'rs-1',
|
|
624
|
+
is_error: true,
|
|
625
|
+
result: '',
|
|
626
|
+
num_rounds: 0,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
expect(db.prepare('SELECT status FROM turns WHERE id = ?').get('t1')).toEqual({
|
|
630
|
+
status: 'aborted',
|
|
631
|
+
});
|
|
632
|
+
expect(captured.find((b) => b.event === 'turn:cancelled')).toBeDefined();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('replayed messages after reconnect are projected idempotently (no duplicate items)', async () => {
|
|
636
|
+
await startedTurn('t1');
|
|
637
|
+
|
|
638
|
+
deliver({
|
|
639
|
+
type: 'assistant',
|
|
640
|
+
sessionId: 'rs-1',
|
|
641
|
+
inputId: 'in-1',
|
|
642
|
+
content: [{ type: 'text', text: 'Hello world' }],
|
|
643
|
+
});
|
|
644
|
+
deliver({
|
|
645
|
+
type: 'user',
|
|
646
|
+
sessionId: 'rs-1',
|
|
647
|
+
inputId: 'in-1',
|
|
648
|
+
content: [
|
|
649
|
+
{
|
|
650
|
+
type: 'tool_result',
|
|
651
|
+
tool_use_id: 'a1',
|
|
652
|
+
content: 'created',
|
|
653
|
+
is_error: false,
|
|
654
|
+
action: {
|
|
655
|
+
id: 'a1',
|
|
656
|
+
action_type: 'create_file',
|
|
657
|
+
file_path: 'src/a.ts',
|
|
658
|
+
command_string: null,
|
|
659
|
+
status: 'CONFIRMED_KEPT',
|
|
660
|
+
server_name: null,
|
|
661
|
+
tool_name: null,
|
|
662
|
+
arguments: null,
|
|
663
|
+
plain: null,
|
|
664
|
+
selections: null,
|
|
665
|
+
order_of_execution: 0,
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Reconnect: the SAME messages replay on a second stream call.
|
|
672
|
+
streamCalls[0].resolve('aborted'); // stall-style disconnect
|
|
673
|
+
await vi.waitFor(() => expect(streamCalls.length).toBe(2));
|
|
674
|
+
deliver({
|
|
675
|
+
type: 'assistant',
|
|
676
|
+
sessionId: 'rs-1',
|
|
677
|
+
inputId: 'in-1',
|
|
678
|
+
content: [{ type: 'text', text: 'Hello world' }],
|
|
679
|
+
});
|
|
680
|
+
deliver({
|
|
681
|
+
type: 'user',
|
|
682
|
+
sessionId: 'rs-1',
|
|
683
|
+
inputId: 'in-1',
|
|
684
|
+
content: [
|
|
685
|
+
{
|
|
686
|
+
type: 'tool_result',
|
|
687
|
+
tool_use_id: 'a1',
|
|
688
|
+
content: 'created',
|
|
689
|
+
is_error: false,
|
|
690
|
+
action: {
|
|
691
|
+
id: 'a1',
|
|
692
|
+
action_type: 'create_file',
|
|
693
|
+
file_path: 'src/a.ts',
|
|
694
|
+
command_string: null,
|
|
695
|
+
status: 'CONFIRMED_KEPT',
|
|
696
|
+
server_name: null,
|
|
697
|
+
tool_name: null,
|
|
698
|
+
arguments: null,
|
|
699
|
+
plain: null,
|
|
700
|
+
selections: null,
|
|
701
|
+
order_of_execution: 0,
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const items = db.prepare('SELECT external_item_id FROM items WHERE turn_id = ?').all('t1') as Array<{
|
|
708
|
+
external_item_id: string;
|
|
709
|
+
}>;
|
|
710
|
+
expect(items.map((i) => i.external_item_id).sort()).toEqual(['in-1-action-a1', 'in-1-text']);
|
|
711
|
+
|
|
712
|
+
deliver({
|
|
713
|
+
type: 'result',
|
|
714
|
+
subtype: 'success',
|
|
715
|
+
sessionId: 'rs-1',
|
|
716
|
+
is_error: false,
|
|
717
|
+
result: 'done',
|
|
718
|
+
num_rounds: 1,
|
|
719
|
+
});
|
|
720
|
+
expect(db.prepare('SELECT status FROM turns WHERE id = ?').get('t1')).toEqual({
|
|
721
|
+
status: 'completed',
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('stream session-not-found fails the turn', async () => {
|
|
726
|
+
await startedTurn('t1');
|
|
727
|
+
|
|
728
|
+
streamCalls[0].resolve('session-not-found');
|
|
729
|
+
await vi.waitFor(() => {
|
|
730
|
+
const failedTurn = db.prepare('SELECT status, error FROM turns WHERE id = ?').get('t1') as
|
|
731
|
+
| { status: string; error: string | null }
|
|
732
|
+
| undefined;
|
|
733
|
+
expect(failedTurn?.status).toBe('failed');
|
|
734
|
+
expect(failedTurn?.error).toMatch(/no longer exists/);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('interruptTurn cancels the runtime round and aborts the turn', async () => {
|
|
739
|
+
await startedTurn('t1');
|
|
740
|
+
|
|
741
|
+
const cancelled = await repoburgProvider.interruptTurn('s1');
|
|
742
|
+
expect(cancelled).toBe(true);
|
|
743
|
+
expect(mock.client.cancel).toHaveBeenCalledWith('rs-1');
|
|
744
|
+
|
|
745
|
+
expect(db.prepare('SELECT status FROM turns WHERE id = ?').get('t1')).toEqual({
|
|
746
|
+
status: 'aborted',
|
|
747
|
+
});
|
|
748
|
+
expect(captured.find((b) => b.event === 'turn:cancelled')).toBeDefined();
|
|
749
|
+
|
|
750
|
+
// Messages after cancellation are ignored — the turn stays aborted.
|
|
751
|
+
deliver({ type: 'result', subtype: 'success', sessionId: 'rs-1', is_error: false, result: 'late', num_rounds: 1 });
|
|
752
|
+
expect(db.prepare('SELECT status FROM turns WHERE id = ?').get('t1')).toEqual({
|
|
753
|
+
status: 'aborted',
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// The interrupted stream signal is aborted (fetch closed).
|
|
757
|
+
expect(streamCalls[0]!.opts.signal!.aborted).toBe(true);
|
|
758
|
+
streamCalls[0]!.resolve('aborted');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('interruptTurn returns false for unknown session', async () => {
|
|
762
|
+
expect(await repoburgProvider.interruptTurn('unknown')).toBe(false);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it('clearThread removes session state', async () => {
|
|
766
|
+
await startedTurn('t1');
|
|
767
|
+
|
|
768
|
+
repoburgProvider.clearThread('s1');
|
|
769
|
+
expect(await repoburgProvider.interruptTurn('s1')).toBe(false);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('clearAllThreads and shutdown do not throw when empty', async () => {
|
|
773
|
+
expect(() => repoburgProvider.clearAllThreads()).not.toThrow();
|
|
774
|
+
await expect(repoburgProvider.shutdown()).resolves.not.toThrow();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('streamAgentRuntimeMessages is imported once and used per turn', () => {
|
|
778
|
+
expect(streamAgentRuntimeMessages).toBeDefined();
|
|
779
|
+
});
|
|
780
|
+
});
|