ompchamber 0.8.0 → 1.1.0
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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -25,10 +25,14 @@ const ABORTED_TURN = {
|
|
|
25
25
|
function makeDeps() {
|
|
26
26
|
const ended: ChatMessageData[] = [];
|
|
27
27
|
const activity: string[] = [];
|
|
28
|
+
const commandOutputs: string[] = [];
|
|
29
|
+
let settledCount = 0;
|
|
28
30
|
let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
|
|
29
31
|
const callbacks: OmpAgentCallbacks = {
|
|
30
32
|
onMessageEnd: (msg) => ended.push(msg),
|
|
31
33
|
onActivity: (verb) => activity.push(verb),
|
|
34
|
+
onPromptSettled: () => { settledCount += 1; },
|
|
35
|
+
onCommandOutput: (text) => commandOutputs.push(text),
|
|
32
36
|
};
|
|
33
37
|
const deps: OmpAgentFoldDeps = {
|
|
34
38
|
sessionId: 's1',
|
|
@@ -43,7 +47,7 @@ function makeDeps() {
|
|
|
43
47
|
currentThinkingLevelRef: { current: undefined },
|
|
44
48
|
fileMutatingCallsRef: { current: new Set<string>() },
|
|
45
49
|
};
|
|
46
|
-
return { deps, ended, activity };
|
|
50
|
+
return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
describe('toChatMessage error derivation', () => {
|
|
@@ -60,6 +64,38 @@ describe('toChatMessage error derivation', () => {
|
|
|
60
64
|
});
|
|
61
65
|
});
|
|
62
66
|
|
|
67
|
+
describe('foldAgentEvent prompt_result (builtin slash commands)', () => {
|
|
68
|
+
test('agentInvoked:false settles generating state and fires onPromptSettled', () => {
|
|
69
|
+
const { deps, state, settled } = makeDeps();
|
|
70
|
+
foldAgentEvent({ type: 'agent_start' }, deps);
|
|
71
|
+
expect(state().isGenerating).toBe(true);
|
|
72
|
+
foldAgentEvent({ type: 'prompt_result', agentInvoked: false }, deps);
|
|
73
|
+
expect(state().isGenerating).toBe(false);
|
|
74
|
+
expect(settled()).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('prompt_result with agentInvoked:true does not settle (a run follows)', () => {
|
|
78
|
+
const { deps, settled } = makeDeps();
|
|
79
|
+
foldAgentEvent({ type: 'prompt_result', agentInvoked: true }, deps);
|
|
80
|
+
expect(settled()).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('foldAgentEvent command_output (builtin slash output)', () => {
|
|
85
|
+
test('forwards trimmed text to onCommandOutput', () => {
|
|
86
|
+
const { deps, commandOutputs } = makeDeps();
|
|
87
|
+
foldAgentEvent({ type: 'command_output', text: 'Usage\nInput tokens: 0\n' }, deps);
|
|
88
|
+
expect(commandOutputs).toEqual(['Usage\nInput tokens: 0']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('drops empty output frames', () => {
|
|
92
|
+
const { deps, commandOutputs } = makeDeps();
|
|
93
|
+
foldAgentEvent({ type: 'command_output', text: ' ' }, deps);
|
|
94
|
+
foldAgentEvent({ type: 'command_output' }, deps);
|
|
95
|
+
expect(commandOutputs).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
63
99
|
describe('foldAgentEvent activity phrases', () => {
|
|
64
100
|
test('names the tool being executed, not a generic reasoning verb', () => {
|
|
65
101
|
const { deps, activity } = makeDeps();
|
|
@@ -165,6 +165,11 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
165
165
|
callbacks?.onAgentStart?.();
|
|
166
166
|
break;
|
|
167
167
|
|
|
168
|
+
case 'turn_start':
|
|
169
|
+
setActivity(PHASE_VERBS.thinking, deps);
|
|
170
|
+
callbacks?.onTurnStart?.();
|
|
171
|
+
break;
|
|
172
|
+
|
|
168
173
|
case 'message_start':
|
|
169
174
|
case 'message_update': {
|
|
170
175
|
const msg = data.message as Record<string, unknown> | undefined;
|
|
@@ -177,6 +182,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
177
182
|
// Assistant phases (thinking / prose / tool-call assembly) name the
|
|
178
183
|
// action directly — a tool that never starts still shows its phrase.
|
|
179
184
|
setActivity(describeAssistantPhase(data.assistantMessageEvent), deps);
|
|
185
|
+
// The first message frame of a turn is another proof the run is live —
|
|
186
|
+
// re-signal so the sidebar can restore a lost `stream` status row.
|
|
187
|
+
// message_update frames arrive per delta and must stay silent here.
|
|
188
|
+
if (data.type === 'message_start') callbacks?.onTurnStart?.();
|
|
180
189
|
// Steering (abort_and_prompt) and follow-up deliveries create no
|
|
181
190
|
// optimistic bubble — the user turn only exists on the stream. Convert
|
|
182
191
|
// it here so the bubble renders live instead of after a JSONL reload.
|
|
@@ -298,6 +307,27 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
298
307
|
break;
|
|
299
308
|
}
|
|
300
309
|
|
|
310
|
+
// omp's prompt ack ran a built-in slash command instead of an agent turn
|
|
311
|
+
// (`agentInvoked:false`, mirrored server-side as this frame): there will be
|
|
312
|
+
// no agent_start/agent_end pair. Clear the optimistic generating state —
|
|
313
|
+
// the callbacks drop the AI placeholder and keep the user's message row.
|
|
314
|
+
case 'prompt_result': {
|
|
315
|
+
if (data.agentInvoked === false) {
|
|
316
|
+
deps.setState((prev) => ({ ...prev, isGenerating: false }));
|
|
317
|
+
deps.activityRef.current = '';
|
|
318
|
+
callbacks?.onPromptSettled?.();
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Built-in slash command output (/usage, /compact result, …). Rendered as
|
|
324
|
+
// a notice row — see the callback type note: this frame is the ONLY copy.
|
|
325
|
+
case 'command_output': {
|
|
326
|
+
const text = typeof data.text === 'string' ? data.text.trim() : '';
|
|
327
|
+
if (text) callbacks?.onCommandOutput?.(text);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
|
|
301
331
|
case 'notice': {
|
|
302
332
|
callbacks?.onNotice?.(
|
|
303
333
|
typeof data.level === 'string' ? data.level : 'info',
|
|
@@ -63,7 +63,6 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
|
|
|
63
63
|
|
|
64
64
|
function makeCallbacks(): OmpAgentCallbacks {
|
|
65
65
|
const deps: OmpAgentCallbacksDeps = {
|
|
66
|
-
removeDeliveredFromQueue: () => {},
|
|
67
66
|
setGenerating: () => {},
|
|
68
67
|
setGeneratingVerb: () => {},
|
|
69
68
|
scrollToBottom: () => {},
|
|
@@ -133,6 +132,15 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
|
|
|
133
132
|
expect(signals).toHaveLength(2);
|
|
134
133
|
});
|
|
135
134
|
|
|
135
|
+
test('onTurnStart signals the sidebar on every turn of the run', () => {
|
|
136
|
+
const callbacks = makeCallbacks();
|
|
137
|
+
|
|
138
|
+
callbacks.onTurnStart?.();
|
|
139
|
+
callbacks.onTurnStart?.();
|
|
140
|
+
expect(signals).toHaveLength(2);
|
|
141
|
+
expect(signals.every(s => s.name === 'omp:session-updated' && s.sessionId === 'sess-1')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
136
144
|
test('never signals from user turns or notice rows alone', () => {
|
|
137
145
|
const callbacks = makeCallbacks();
|
|
138
146
|
|
|
@@ -47,7 +47,6 @@ export function cancelStreamingCoalescer(): void {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface OmpAgentCallbacksDeps {
|
|
50
|
-
removeDeliveredFromQueue: (text: string) => void;
|
|
51
50
|
setGenerating: (v: boolean) => void;
|
|
52
51
|
setGeneratingVerb: (v: string) => void;
|
|
53
52
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
@@ -73,7 +72,6 @@ export interface OmpAgentCallbacksDeps {
|
|
|
73
72
|
|
|
74
73
|
export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
|
|
75
74
|
const {
|
|
76
|
-
removeDeliveredFromQueue,
|
|
77
75
|
setGenerating,
|
|
78
76
|
setGeneratingVerb,
|
|
79
77
|
scrollToBottom,
|
|
@@ -97,12 +95,6 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
97
95
|
scrollAfterFlush = () => scrollToBottom('smooth');
|
|
98
96
|
|
|
99
97
|
return {
|
|
100
|
-
// A queued steer/follow-up text was picked up by the agent (user turn
|
|
101
|
-
// arrived) — drop it from whichever queue mirrors it so the panel stays
|
|
102
|
-
// truthful without a chat bubble for every delivery.
|
|
103
|
-
onQueuedMessageDelivered: (text) => {
|
|
104
|
-
removeDeliveredFromQueue(text);
|
|
105
|
-
},
|
|
106
98
|
onAgentStart: () => {
|
|
107
99
|
setGenerating(true);
|
|
108
100
|
setGeneratingVerb(PHASE_VERBS.thinking);
|
|
@@ -122,6 +114,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
122
114
|
}
|
|
123
115
|
}
|
|
124
116
|
},
|
|
117
|
+
onTurnStart: () => {
|
|
118
|
+
// Every turn inside the run re-signals the sidebar: the throttle makes
|
|
119
|
+
// this cheap, and it recovers the `stream` status row if a previous
|
|
120
|
+
// dispatch raced with the status write (or the row was healed away).
|
|
121
|
+
const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
|
|
122
|
+
if (sid && typeof window !== 'undefined') {
|
|
123
|
+
window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
125
126
|
// Reload recovery: the omp process kept running server-side, so the event
|
|
126
127
|
// stream is reattached and the generating UI must resume (the timeline
|
|
127
128
|
// fetch already loaded the committed messages; live updates continue).
|
|
@@ -308,6 +309,42 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
308
309
|
abortControllerRef.current = null;
|
|
309
310
|
console.error('OMP prompt error:', errorMessage);
|
|
310
311
|
},
|
|
312
|
+
// The prompt ran a built-in slash command (omp answered on the command
|
|
313
|
+
// path, no agent run): drop the optimistic AI placeholder and settle the
|
|
314
|
+
// optimistic user row — it stays as the record of what the user invoked.
|
|
315
|
+
onPromptSettled: () => {
|
|
316
|
+
disposeStreamingCoalescer();
|
|
317
|
+
setGenerating(false);
|
|
318
|
+
abortControllerRef.current = null;
|
|
319
|
+
optimisticUserIdRef.current = null;
|
|
320
|
+
const placeholderId = aiPlaceholderIdRef.current;
|
|
321
|
+
aiPlaceholderIdRef.current = null;
|
|
322
|
+
if (placeholderId) {
|
|
323
|
+
setLocalMessages(prev => {
|
|
324
|
+
if (!prev.some(m => m.id === placeholderId)) return prev;
|
|
325
|
+
return prev.filter(m => m.id !== placeholderId);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
// Built-in slash command output only rides the live stream — append it as
|
|
330
|
+
// a notice row before any active placeholder, exactly like omp notices.
|
|
331
|
+
onCommandOutput: (text) => {
|
|
332
|
+
const row: ChatMessageData = {
|
|
333
|
+
id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
334
|
+
role: 'ai',
|
|
335
|
+
content: '',
|
|
336
|
+
notice: text,
|
|
337
|
+
};
|
|
338
|
+
setLocalMessages(prev => {
|
|
339
|
+
const placeholderId = aiPlaceholderIdRef.current;
|
|
340
|
+
if (placeholderId) {
|
|
341
|
+
const pIdx = prev.findIndex(m => m.id === placeholderId);
|
|
342
|
+
if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
|
|
343
|
+
}
|
|
344
|
+
if (prev.some(m => m.notice === text)) return prev;
|
|
345
|
+
return [...prev, row];
|
|
346
|
+
});
|
|
347
|
+
},
|
|
311
348
|
onNotice: (_level, message) => {
|
|
312
349
|
console.info('OMP notice:', message);
|
|
313
350
|
},
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The preference rules are shared verbatim by the server (which persists the
|
|
8
|
+
* arrays) and the picker (which applies them optimistically), so these lock the
|
|
9
|
+
* behaviour both sides read back: composite keys, most-recent-first order, and
|
|
10
|
+
* the five-entry cap.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
EMPTY_MODEL_PREFERENCES,
|
|
17
|
+
RECENT_MODELS_LIMIT,
|
|
18
|
+
applyModelPreferences,
|
|
19
|
+
filterKnownKeys,
|
|
20
|
+
readModelPreferences,
|
|
21
|
+
recordRecentKey,
|
|
22
|
+
toggleFavoriteKey,
|
|
23
|
+
} from '@/shared/lib/models/preferences';
|
|
24
|
+
import type { AIModelOption } from '@/shared/types';
|
|
25
|
+
|
|
26
|
+
describe('toggleFavoriteKey', () => {
|
|
27
|
+
test('adds an unknown key at the end and removes a known one', () => {
|
|
28
|
+
expect(toggleFavoriteKey(['a:1'], 'b:2')).toEqual(['a:1', 'b:2']);
|
|
29
|
+
expect(toggleFavoriteKey(['a:1', 'b:2'], 'a:1')).toEqual(['b:2']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('keys on provider + id, so the same id under another provider is a separate favorite', () => {
|
|
33
|
+
// The registry serves `deepseek-v4-pro` from both `deepseek` and `kenari`.
|
|
34
|
+
// An id-only list would unstar both rows when either is clicked.
|
|
35
|
+
const favorites = toggleFavoriteKey([], 'deepseek:deepseek-v4-pro');
|
|
36
|
+
expect(toggleFavoriteKey(favorites, 'kenari:deepseek-v4-pro')).toEqual([
|
|
37
|
+
'deepseek:deepseek-v4-pro',
|
|
38
|
+
'kenari:deepseek-v4-pro',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('recordRecentKey', () => {
|
|
44
|
+
test('moves a re-picked model to the front instead of duplicating it', () => {
|
|
45
|
+
expect(recordRecentKey(['a:1', 'b:2', 'c:3'], 'b:2')).toEqual(['b:2', 'a:1', 'c:3']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('caps the list at five, evicting the oldest entry', () => {
|
|
49
|
+
const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
|
|
50
|
+
const next = recordRecentKey(full, 'f:6');
|
|
51
|
+
|
|
52
|
+
expect(next).toHaveLength(RECENT_MODELS_LIMIT);
|
|
53
|
+
expect(next[0]).toBe('f:6');
|
|
54
|
+
expect(next).not.toContain('e:5');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('re-picking inside a full list keeps the other four', () => {
|
|
58
|
+
const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
|
|
59
|
+
expect(recordRecentKey(full, 'c:3')).toEqual(['c:3', 'a:1', 'b:2', 'd:4', 'e:5']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('readModelPreferences', () => {
|
|
64
|
+
test('reads a well-formed pair', () => {
|
|
65
|
+
expect(readModelPreferences({ favorites: ['a:1'], recentKeys: ['b:2'] }))
|
|
66
|
+
.toEqual({ favorites: ['a:1'], recentKeys: ['b:2'] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('drops non-string entries and caps an over-long recent list', () => {
|
|
70
|
+
const raw = {
|
|
71
|
+
favorites: ['a:1', 7, null, ''],
|
|
72
|
+
recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5', 'f:6', 'g:7'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
expect(readModelPreferences(raw)).toEqual({
|
|
76
|
+
favorites: ['a:1'],
|
|
77
|
+
recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an absent or malformed row yields empty rails', () => {
|
|
82
|
+
expect(readModelPreferences(null)).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
83
|
+
expect(readModelPreferences('nonsense')).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
84
|
+
expect(readModelPreferences({ favorites: 'not-a-list' })).toEqual(EMPTY_MODEL_PREFERENCES);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('filterKnownKeys', () => {
|
|
89
|
+
test('drops keys the registry no longer serves', () => {
|
|
90
|
+
// A model removed from models.yml, or every model of a disconnected
|
|
91
|
+
// provider, must not hold a slot in the five-entry rail.
|
|
92
|
+
const known = new Set(['a:1', 'c:3']);
|
|
93
|
+
expect(filterKnownKeys(['a:1', 'gone:9', 'c:3'], known)).toEqual(['a:1', 'c:3']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('pruning before a write frees the slot instead of merely hiding it', () => {
|
|
97
|
+
// The rail reads at most five entries. A stale key left in the stored array
|
|
98
|
+
// occupies one of them across every reload, so the user sees four models
|
|
99
|
+
// and the fifth pick appears to do nothing.
|
|
100
|
+
const stored = ['gone:9', 'a:1', 'b:2', 'c:3', 'd:4'];
|
|
101
|
+
const pruned = recordRecentKey(filterKnownKeys(stored, new Set(['a:1', 'b:2', 'c:3', 'd:4', 'e:5'])), 'e:5');
|
|
102
|
+
|
|
103
|
+
expect(pruned).toEqual(['e:5', 'a:1', 'b:2', 'c:3', 'd:4']);
|
|
104
|
+
expect(pruned).toHaveLength(RECENT_MODELS_LIMIT);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('applyModelPreferences', () => {
|
|
109
|
+
const rows: AIModelOption[] = [
|
|
110
|
+
{ id: '1', name: 'One', provider: 'a' },
|
|
111
|
+
{ id: '2', name: 'Two', provider: 'a', isFavorite: true },
|
|
112
|
+
{ id: '3', name: 'Three', provider: 'b' },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
test('lights only the rows named by the stored key list', () => {
|
|
116
|
+
const applied = applyModelPreferences(rows, { favorites: ['b:3'], recentKeys: [] });
|
|
117
|
+
|
|
118
|
+
// Truthiness is the contract the star reads: a registry row has no
|
|
119
|
+
// `isFavorite` field at all, so `false` and `undefined` both mean "unlit".
|
|
120
|
+
expect(applied.map((m) => Boolean(m.isFavorite))).toEqual([false, false, true]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('leaves already-correct rows identical so only the changed row re-renders', () => {
|
|
124
|
+
const applied = applyModelPreferences(rows, { favorites: [], recentKeys: [] });
|
|
125
|
+
|
|
126
|
+
expect(applied[0]).toBe(rows[0]);
|
|
127
|
+
expect(applied[2]).toBe(rows[2]);
|
|
128
|
+
expect(applied[1]).not.toBe(rows[1]);
|
|
129
|
+
expect(applied[1].isFavorite).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Favorite + recent model preferences, keyed by the composite `provider:id`
|
|
8
|
+
* identity (`modelKey`). A model id alone is not unique — the omp registry
|
|
9
|
+
* serves the same id from several providers — so an id-keyed favorite list
|
|
10
|
+
* lights up every provider's row and records the wrong "recent".
|
|
11
|
+
*
|
|
12
|
+
* Pure list operations only: the server persists the arrays (one
|
|
13
|
+
* `app_settings` row) and the picker applies them optimistically, so both
|
|
14
|
+
* sides MUST agree on the ordering and the cap. Keeping the rules here is what
|
|
15
|
+
* makes them agree — a second copy in the route or the hook would drift.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AIModelOption, ModelPreferences } from '@/shared/types';
|
|
19
|
+
import { modelKey } from '@/shared/lib/models/identity';
|
|
20
|
+
|
|
21
|
+
/** How many last-used models the RECENT rail holds. */
|
|
22
|
+
export const RECENT_MODELS_LIMIT = 5;
|
|
23
|
+
|
|
24
|
+
/** Used before the first write, and when a payload carries no preferences. */
|
|
25
|
+
export const EMPTY_MODEL_PREFERENCES: ModelPreferences = { favorites: [], recentKeys: [] };
|
|
26
|
+
|
|
27
|
+
/** Add or remove one key; the relative order of the survivors is untouched. */
|
|
28
|
+
export function toggleFavoriteKey(favorites: readonly string[], key: string): string[] {
|
|
29
|
+
return favorites.includes(key)
|
|
30
|
+
? favorites.filter((entry) => entry !== key)
|
|
31
|
+
: [...favorites, key];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Move a key to the front of the recents, de-duplicated and capped. Re-picking
|
|
36
|
+
* a model must not leave a second copy behind — the rail would then show the
|
|
37
|
+
* same model twice and the cap would evict a genuinely older entry.
|
|
38
|
+
*/
|
|
39
|
+
export function recordRecentKey(
|
|
40
|
+
recentKeys: readonly string[],
|
|
41
|
+
key: string,
|
|
42
|
+
limit: number = RECENT_MODELS_LIMIT,
|
|
43
|
+
): string[] {
|
|
44
|
+
return [key, ...recentKeys.filter((entry) => entry !== key)].slice(0, Math.max(1, limit));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drop keys the live registry no longer offers — a model removed from
|
|
49
|
+
* models.yml, or every model of a provider the user disconnected. Without this
|
|
50
|
+
* a stale key holds one of the five recent slots forever, so a rail that looks
|
|
51
|
+
* empty is hiding real entries behind keys nothing can render.
|
|
52
|
+
*/
|
|
53
|
+
export function filterKnownKeys(keys: readonly string[], known: ReadonlySet<string>): string[] {
|
|
54
|
+
return keys.filter((key) => known.has(key));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse a persisted preferences row without trusting the JSON shape. */
|
|
58
|
+
export function readModelPreferences(value: unknown): ModelPreferences {
|
|
59
|
+
const source = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
|
|
60
|
+
const strings = (input: unknown): string[] =>
|
|
61
|
+
Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
|
|
62
|
+
return {
|
|
63
|
+
favorites: strings(source.favorites),
|
|
64
|
+
recentKeys: strings(source.recentKeys).slice(0, RECENT_MODELS_LIMIT),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stamp the favorite flag onto a catalog list. The registry carries no
|
|
70
|
+
* favorites of its own — it is a read-only view of omp's config — so the picker
|
|
71
|
+
* learns which rows are starred from the preference store, never from the
|
|
72
|
+
* catalog's own `isFavorite` (that is MOCK seed data).
|
|
73
|
+
*
|
|
74
|
+
* The comparison is by truthiness, not identity: a registry row has no
|
|
75
|
+
* `isFavorite` field at all, so an `undefined !== false` test would rebuild
|
|
76
|
+
* every row on every load and re-render the whole panel. Rows whose star state
|
|
77
|
+
* already reads correctly are returned untouched.
|
|
78
|
+
*/
|
|
79
|
+
export function applyModelPreferences(
|
|
80
|
+
models: AIModelOption[],
|
|
81
|
+
preferences: ModelPreferences,
|
|
82
|
+
): AIModelOption[] {
|
|
83
|
+
const favorites = new Set(preferences.favorites);
|
|
84
|
+
return models.map((model) => {
|
|
85
|
+
const isFavorite = favorites.has(modelKey(model));
|
|
86
|
+
return Boolean(model.isFavorite) === isFavorite ? model : { ...model, isFavorite };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -63,6 +63,14 @@ export function mergeProviderModels(
|
|
|
63
63
|
return { merged: [...existing, ...added], added, addedCount: added.length };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Register newly added provider models on the chamber's catalog.
|
|
68
|
+
*
|
|
69
|
+
* `isFavorite` is deliberately NOT sent: the picker derives favorites from the
|
|
70
|
+
* stored preference keys, so a catalog flag would be a second, silently
|
|
71
|
+
* ignored source of truth for the FAVORITES rail. A new provider's models are
|
|
72
|
+
* starred by the user, not by this sync.
|
|
73
|
+
*/
|
|
66
74
|
export async function syncProviderModelsToCatalog(
|
|
67
75
|
providerName: string,
|
|
68
76
|
models: ProviderModel[],
|
|
@@ -81,7 +89,6 @@ export async function syncProviderModelsToCatalog(
|
|
|
81
89
|
provider: providerName,
|
|
82
90
|
contextWindow: model.contextWindow?.split(' ')[0] || '128K',
|
|
83
91
|
thinkingLevel: 'Default',
|
|
84
|
-
isFavorite: true,
|
|
85
92
|
capabilities: ['Tool calling', 'Reasoning'],
|
|
86
93
|
inputFormats: ['text'],
|
|
87
94
|
outputFormats: ['text'],
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* stream — a run is in flight (prompt dispatch → agent_start)
|
|
12
12
|
* finish — the run ended normally (agent_end), not yet seen
|
|
13
13
|
* abort — the user stopped it, not yet seen
|
|
14
|
-
* error — the run failed (prompt_error / process exit), not yet seen
|
|
15
14
|
*
|
|
16
15
|
* A terminal status is a one-shot badge: opening the session acknowledges it
|
|
17
16
|
* (`markStreamSeen` deletes the row), which is exactly the "check appears
|
|
@@ -25,7 +24,7 @@
|
|
|
25
24
|
|
|
26
25
|
import { getDb } from '@/server/db.server';
|
|
27
26
|
|
|
28
|
-
export type SessionStreamStatus = 'stream' | 'finish' | 'abort'
|
|
27
|
+
export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
|
|
29
28
|
|
|
30
29
|
export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
|
|
31
30
|
try {
|
|
@@ -87,7 +86,7 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
|
|
|
87
86
|
}[];
|
|
88
87
|
const map: Record<string, SessionStreamStatus> = {};
|
|
89
88
|
for (const row of rows) {
|
|
90
|
-
if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort'
|
|
89
|
+
if (row.status === 'stream' || row.status === 'finish' || row.status === 'abort') {
|
|
91
90
|
map[row.session_id] = row.status;
|
|
92
91
|
}
|
|
93
92
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Covers the boundaries of the subagent label derivation: the Target-section
|
|
8
|
+
* scan (heading level, colon variant, section end) and the two identity shapes.
|
|
9
|
+
*
|
|
10
|
+
* These are the cases where the label is derived from prompt text the caller
|
|
11
|
+
* controls, so a wrong boundary silently renders template boilerplate or a
|
|
12
|
+
* section name where the assignment's headline belongs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { assignmentTarget, subagentHeaderTitle, subagentRowLabel } from '@/shared/lib/omp/subagent/label';
|
|
18
|
+
import type { SubagentInfo } from '@/shared/types/omp/subagent';
|
|
19
|
+
|
|
20
|
+
/** Minimal roster entry — only the fields the label helpers read. */
|
|
21
|
+
function makeSubagent(overrides: Partial<SubagentInfo> = {}): SubagentInfo {
|
|
22
|
+
return { id: 'Scout', agent: 'task', status: 'completed', index: 0, ...overrides };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('assignmentTarget', () => {
|
|
26
|
+
test('reads the headline under the task template Target section', () => {
|
|
27
|
+
const task = 'Complete assignment thoroughly:\n\n# Target\nCount .tsx files under src/.\n\n# Change\nNone.\n\n# Acceptance\nReport the integer.';
|
|
28
|
+
expect(assignmentTarget(task)).toBe('Count .tsx files under src/.');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('stops at the next heading so Change prose never becomes the headline', () => {
|
|
32
|
+
const task = '# Target\nMap the panel data model.\n\n# Change\nDo the thing';
|
|
33
|
+
expect(assignmentTarget(task)).toBe('Map the panel data model.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('accepts any heading level and a trailing colon', () => {
|
|
37
|
+
expect(assignmentTarget('## Target:\nHeadline here')).toBe('Headline here');
|
|
38
|
+
expect(assignmentTarget('### Target\nHeadline here')).toBe('Headline here');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('skips an empty Target section instead of promoting the next section', () => {
|
|
42
|
+
expect(assignmentTarget('# Target\n\n# Change\nDo the thing')).toBe('Do the thing');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('falls back to the first prose line when no Target section exists', () => {
|
|
46
|
+
expect(assignmentTarget('Fix the sidebar expand bug')).toBe('Fix the sidebar expand bug');
|
|
47
|
+
expect(assignmentTarget('# Scope\nNot a target section')).toBe('Not a target section');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('never returns the template lead-in or a bare heading', () => {
|
|
51
|
+
expect(assignmentTarget('Complete assignment thoroughly:')).toBeUndefined();
|
|
52
|
+
expect(assignmentTarget('# Target')).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns undefined for absent or empty text', () => {
|
|
56
|
+
expect(assignmentTarget(undefined)).toBeUndefined();
|
|
57
|
+
expect(assignmentTarget(' \n\n ')).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('subagentHeaderTitle', () => {
|
|
62
|
+
test('appends the model role recorded on a history entry', () => {
|
|
63
|
+
expect(subagentHeaderTitle(makeSubagent({ modelRole: 'smol' }))).toBe('Scout (task:smol)');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('falls back to the role carried by a live progress frame', () => {
|
|
67
|
+
expect(subagentHeaderTitle(makeSubagent({ progress: { modelRole: 'task' } }))).toBe('Scout (task:task)');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('omits the role when the run recorded none', () => {
|
|
71
|
+
expect(subagentHeaderTitle(makeSubagent())).toBe('Scout (task)');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('subagentRowLabel', () => {
|
|
76
|
+
test('appends the assignment headline to the identity', () => {
|
|
77
|
+
const subagent = makeSubagent({ task: 'Complete assignment thoroughly:\n\n# Target\nHeadline here.\n\n# Change\nx' });
|
|
78
|
+
expect(subagentRowLabel(subagent)).toBe('Scout (task): Headline here.');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reads the assignment field when the task template is absent', () => {
|
|
82
|
+
expect(subagentRowLabel(makeSubagent({ assignment: '# Target\nFrom the assignment field.' })))
|
|
83
|
+
.toBe('Scout (task): From the assignment field.');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('falls back to the description, then to the bare identity', () => {
|
|
87
|
+
expect(subagentRowLabel(makeSubagent({ description: 'fallback description' }))).toBe('Scout (task): fallback description');
|
|
88
|
+
expect(subagentRowLabel(makeSubagent())).toBe('Scout (task)');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('keeps the identity when the assignment has no usable headline', () => {
|
|
92
|
+
expect(subagentRowLabel(makeSubagent({ task: '# Target' }))).toBe('Scout (task)');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One-line labels for a subagent roster entry.
|
|
8
|
+
*
|
|
9
|
+
* A delegated assignment is a task prompt, not a sentence. The wire carries it
|
|
10
|
+
* twice: `assignment` is the document the caller wrote, and `task` is that same
|
|
11
|
+
* document wrapped by the task tool's template ("Complete assignment
|
|
12
|
+
* thoroughly:" followed by `# Target` / `# Change` / `# Acceptance` sections).
|
|
13
|
+
* Rendering either verbatim in a 268px sidebar row would spend the whole row on
|
|
14
|
+
* the template's lead-in, so a row shows the Target section's headline — the
|
|
15
|
+
* one line that says what the agent is for.
|
|
16
|
+
*
|
|
17
|
+
* The two surfaces differ only in what they append to the identity:
|
|
18
|
+
* roster row → `id (agent): target`
|
|
19
|
+
* banner → `id (agent:modelRole)`
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { SubagentInfo } from '@/shared/types/omp/subagent';
|
|
23
|
+
|
|
24
|
+
/** Any markdown heading — the boundary between prompt sections. */
|
|
25
|
+
const HEADING_RE = /^#{1,6}\s+\S/;
|
|
26
|
+
/** The section the task prompt template mandates as the assignment's summary. */
|
|
27
|
+
const TARGET_HEADING_RE = /^#{1,6}\s+target\s*:?\s*$/i;
|
|
28
|
+
/** The task template's own lead-in; it says nothing about the assignment. */
|
|
29
|
+
const LEAD_IN_RE = /^complete (the )?assignment\b/i;
|
|
30
|
+
|
|
31
|
+
/** First line of `lines` that has content, trimmed. */
|
|
32
|
+
function firstLine(lines: string[]): string | undefined {
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (trimmed) return trimmed;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The headline of a delegated assignment: the first line of its `# Target`
|
|
42
|
+
* section, falling back to the prompt's own first prose line when there is no
|
|
43
|
+
* such section (or it is empty).
|
|
44
|
+
*/
|
|
45
|
+
export function assignmentTarget(text: string | undefined): string | undefined {
|
|
46
|
+
if (!text) return undefined;
|
|
47
|
+
const lines = text.split('\n');
|
|
48
|
+
const headingAt = lines.findIndex((line) => TARGET_HEADING_RE.test(line.trim()));
|
|
49
|
+
if (headingAt !== -1) {
|
|
50
|
+
// Bounded by the next heading: an empty Target section must not promote the
|
|
51
|
+
// Change/Acceptance prose that follows it into the headline.
|
|
52
|
+
const body = lines.slice(headingAt + 1);
|
|
53
|
+
const nextHeading = body.findIndex((line) => HEADING_RE.test(line.trim()));
|
|
54
|
+
const headline = firstLine(nextHeading === -1 ? body : body.slice(0, nextHeading));
|
|
55
|
+
if (headline) return headline;
|
|
56
|
+
}
|
|
57
|
+
// No Target section, or an empty one: the prompt's first prose line. Headings
|
|
58
|
+
// are skipped because a section name is not a summary of the assignment.
|
|
59
|
+
return firstLine(lines.filter((line) => {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
return !HEADING_RE.test(trimmed) && !LEAD_IN_RE.test(trimmed);
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `id (agent:modelRole)`, or `id (agent)` when no role was recorded. Live
|
|
66
|
+
* snapshots carry the role on `progress`; history entries carry it top-level,
|
|
67
|
+
* because the on-disk fold records it from the parent's task toolResult rather
|
|
68
|
+
* than from a progress frame. */
|
|
69
|
+
export function subagentHeaderTitle(subagent: SubagentInfo): string {
|
|
70
|
+
const role = subagent.modelRole ?? subagent.progress?.modelRole;
|
|
71
|
+
return role
|
|
72
|
+
? `${subagent.id} (${subagent.agent}:${role})`
|
|
73
|
+
: `${subagent.id} (${subagent.agent})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `id (agent): target` — the roster row's one-line label. The description is
|
|
77
|
+
* the fallback for entries whose assignment text never reached the roster. */
|
|
78
|
+
export function subagentRowLabel(subagent: SubagentInfo): string {
|
|
79
|
+
const target = assignmentTarget(subagent.task)
|
|
80
|
+
?? assignmentTarget(subagent.assignment)
|
|
81
|
+
?? subagent.description;
|
|
82
|
+
const identity = `${subagent.id} (${subagent.agent})`;
|
|
83
|
+
return target ? `${identity}: ${target}` : identity;
|
|
84
|
+
}
|