ompchamber 3.1.1 → 3.2.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.
Files changed (109) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +279 -280
  3. package/dist/client/static/js/index-01dv383b.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/cli/lib/output.js +15 -8
  7. package/src/client/components/common/Modal.tsx +14 -4
  8. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  10. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  11. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  12. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  13. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  15. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  16. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  17. package/src/client/components/settings/Modal.tsx +35 -6
  18. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  19. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  20. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  21. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  22. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  23. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  24. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  25. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  26. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  27. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  28. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  29. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  30. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  31. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  32. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  33. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  35. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/master-detail/index.tsx +68 -0
  38. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  41. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  50. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  51. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  52. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  53. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  54. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  55. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  56. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  57. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  58. package/src/client/hooks/chat/subagent/index.ts +37 -11
  59. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  60. package/src/client/hooks/settings/master-detail.ts +33 -0
  61. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  62. package/src/server/lib/db/client.ts +5 -1
  63. package/src/server/lib/fs/git-diff.ts +34 -10
  64. package/src/server/lib/fs/git-repos.ts +81 -0
  65. package/src/server/lib/lifecycle/flock.ts +199 -0
  66. package/src/server/lib/lifecycle/identity.ts +5 -1
  67. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  68. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  69. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  70. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  71. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  72. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  73. package/src/server/lib/observer-ws.ts +150 -0
  74. package/src/server/lib/omp/rpc/constants.ts +9 -0
  75. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  76. package/src/server/lib/omp/session/file-scan.ts +7 -1
  77. package/src/server/lib/omp/session/files.ts +95 -96
  78. package/src/server/lib/omp/session/locator.ts +18 -8
  79. package/src/server/lib/omp/session/messages.ts +7 -7
  80. package/src/server/lib/omp/session/scan.ts +108 -0
  81. package/src/server/lib/omp/session/stats.ts +50 -50
  82. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  83. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  84. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  85. package/src/server/lib/terminal/runtime.server.ts +3 -0
  86. package/src/server/lib/terminal/session.server.ts +30 -3
  87. package/src/server/routes/agent/command.test.ts +69 -0
  88. package/src/server/routes/agent/command.ts +14 -0
  89. package/src/server/routes/agent/ws.test.ts +159 -0
  90. package/src/server/routes/agent/ws.ts +22 -97
  91. package/src/server/routes/btw/ws.ts +22 -95
  92. package/src/server/routes/fs/git.ts +61 -70
  93. package/src/server/routes/omp/session-stats.ts +1 -1
  94. package/src/server/routes/telemetry/tokens.ts +6 -4
  95. package/src/server/routes/terminal/run.ts +4 -8
  96. package/src/server/routes/terminal/ws.ts +24 -2
  97. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  98. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  99. package/src/shared/lib/code/language.ts +146 -0
  100. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  101. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  102. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  103. package/src/shared/lib/fs/diff-parser.ts +67 -42
  104. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  105. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  106. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  107. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  108. package/src/shared/types/omp/subagent.ts +6 -0
  109. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -61,7 +61,7 @@ describe('parseUnifiedDiff', () => {
61
61
  });
62
62
 
63
63
  test('folds a whitespace-only rewrite into context when ignoring whitespace', () => {
64
- const { lines, splitRows, additions, deletions } = parseUnifiedDiff(WHITESPACE_ONLY, true);
64
+ const { lines, splitRows, additions, deletions } = parseUnifiedDiff(WHITESPACE_ONLY, { ignoreWhitespace: true });
65
65
 
66
66
  // The counts the toolbar renders must match what is on screen.
67
67
  expect(additions).toBe(0);
@@ -81,7 +81,7 @@ describe('parseUnifiedDiff', () => {
81
81
  });
82
82
 
83
83
  test('keeps a real change when ignoring whitespace', () => {
84
- const { lines, additions, deletions } = parseUnifiedDiff(REAL_CHANGE, true);
84
+ const { lines, additions, deletions } = parseUnifiedDiff(REAL_CHANGE, { ignoreWhitespace: true });
85
85
 
86
86
  expect(additions).toBe(1);
87
87
  expect(deletions).toBe(1);
@@ -90,7 +90,7 @@ describe('parseUnifiedDiff', () => {
90
90
  });
91
91
 
92
92
  test('folds only the whitespace pair of a mixed hunk', () => {
93
- const { lines, additions, deletions } = parseUnifiedDiff(MIXED, true);
93
+ const { lines, additions, deletions } = parseUnifiedDiff(MIXED, { ignoreWhitespace: true });
94
94
 
95
95
  // Line 1 differs by indentation only; line 2 is a genuine edit.
96
96
  expect(additions).toBe(1);
@@ -110,7 +110,7 @@ describe('parseUnifiedDiff', () => {
110
110
  });
111
111
 
112
112
  test('returns empty results for an empty diff', () => {
113
- expect(parseUnifiedDiff('', true)).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
114
- expect(parseUnifiedDiff(' \n', false)).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
113
+ expect(parseUnifiedDiff('', { ignoreWhitespace: true })).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
114
+ expect(parseUnifiedDiff(' \n', { ignoreWhitespace: false })).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
115
115
  });
116
116
  });
@@ -31,12 +31,29 @@ interface PendingChange {
31
31
  * which is what this used to do — left the row painted red/green and counted in
32
32
  * `+n/-n`, so the toggle changed the glyphs and nothing else.
33
33
  */
34
- export function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = false): {
34
+ export interface ParseDiffOptions {
35
+ /** Fold `-`/`+` pairs that differ only in whitespace back into context. */
36
+ ignoreWhitespace?: boolean;
37
+ /**
38
+ * Which view the caller renders. Both views are built from one flush so a
39
+ * decision cannot land in one and not the other, but building the one nobody
40
+ * shows doubled the allocations on a full-context diff — an 80k-line file
41
+ * produced ~60k discarded `SplitDiffRow` objects. `'both'` is the default so
42
+ * the counts and the shared flush stay exactly as they were.
43
+ */
44
+ views?: 'unified' | 'split' | 'both';
45
+ }
46
+
47
+ export function parseUnifiedDiff(rawDiff: string, options: ParseDiffOptions = {}): {
35
48
  lines: DiffLine[];
36
49
  splitRows: SplitDiffRow[];
37
50
  additions: number;
38
51
  deletions: number;
39
52
  } {
53
+ const ignoreWhitespace = options.ignoreWhitespace ?? false;
54
+ const views = options.views ?? 'both';
55
+ const wantUnified = views !== 'split';
56
+ const wantSplit = views !== 'unified';
40
57
  const lines: DiffLine[] = [];
41
58
  const splitRows: SplitDiffRow[] = [];
42
59
  let additions = 0;
@@ -73,43 +90,47 @@ export function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = fa
73
90
  }
74
91
  }
75
92
 
76
- for (let i = 0; i < delBuffer.length; i++) {
77
- const del = delBuffer[i];
78
- const add = addBuffer[i];
79
- if (folded[i] && add) {
80
- // Show it as unchanged on both sides: the change is still in the file,
81
- // the reader asked not to be shown formatting noise.
82
- lines.push({
83
- type: 'context',
84
- // The new form, so the row reads as the file's current state.
85
- text: add.text,
86
- oldLineNumber: del.lineNumber,
87
- newLineNumber: add.lineNumber,
88
- });
89
- continue;
93
+ if (wantUnified) {
94
+ for (let i = 0; i < delBuffer.length; i++) {
95
+ const del = delBuffer[i];
96
+ const add = addBuffer[i];
97
+ if (folded[i] && add) {
98
+ // Show it as unchanged on both sides: the change is still in the file,
99
+ // the reader asked not to be shown formatting noise.
100
+ lines.push({
101
+ type: 'context',
102
+ // The new form, so the row reads as the file's current state.
103
+ text: add.text,
104
+ oldLineNumber: del.lineNumber,
105
+ newLineNumber: add.lineNumber,
106
+ });
107
+ continue;
108
+ }
109
+ lines.push({ type: 'del', text: del.text, oldLineNumber: del.lineNumber });
110
+ }
111
+ for (let i = 0; i < addBuffer.length; i++) {
112
+ const add = addBuffer[i];
113
+ if (folded[i]) continue;
114
+ lines.push({ type: 'add', text: add.text, newLineNumber: add.lineNumber });
90
115
  }
91
- lines.push({ type: 'del', text: del.text, oldLineNumber: del.lineNumber });
92
- }
93
- for (let i = 0; i < addBuffer.length; i++) {
94
- const add = addBuffer[i];
95
- if (folded[i]) continue;
96
- lines.push({ type: 'add', text: add.text, newLineNumber: add.lineNumber });
97
116
  }
98
117
 
99
- for (let i = 0; i < maxLen; i++) {
100
- const del = delBuffer[i];
101
- const add = addBuffer[i];
102
- if (folded[i] && del && add) {
118
+ if (wantSplit) {
119
+ for (let i = 0; i < maxLen; i++) {
120
+ const del = delBuffer[i];
121
+ const add = addBuffer[i];
122
+ if (folded[i] && del && add) {
123
+ splitRows.push({
124
+ left: { type: 'context', text: del.text, lineNumber: del.lineNumber },
125
+ right: { type: 'context', text: add.text, lineNumber: add.lineNumber },
126
+ });
127
+ continue;
128
+ }
103
129
  splitRows.push({
104
- left: { type: 'context', text: del.text, lineNumber: del.lineNumber },
105
- right: { type: 'context', text: add.text, lineNumber: add.lineNumber },
130
+ left: del ? { type: 'del', text: del.text, lineNumber: del.lineNumber } : undefined,
131
+ right: add ? { type: 'add', text: add.text, lineNumber: add.lineNumber } : undefined,
106
132
  });
107
- continue;
108
133
  }
109
- splitRows.push({
110
- left: del ? { type: 'del', text: del.text, lineNumber: del.lineNumber } : undefined,
111
- right: add ? { type: 'add', text: add.text, lineNumber: add.lineNumber } : undefined,
112
- });
113
134
  }
114
135
  delBuffer = [];
115
136
  addBuffer = [];
@@ -156,16 +177,20 @@ export function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = fa
156
177
  } else {
157
178
  flushBuffers();
158
179
  const text = rawLine.startsWith(' ') ? rawLine.slice(1) : rawLine;
159
- lines.push({
160
- type: 'context',
161
- text,
162
- oldLineNumber: oldCounter,
163
- newLineNumber: newCounter,
164
- });
165
- splitRows.push({
166
- left: { type: 'context', text, lineNumber: oldCounter },
167
- right: { type: 'context', text, lineNumber: newCounter },
168
- });
180
+ if (wantUnified) {
181
+ lines.push({
182
+ type: 'context',
183
+ text,
184
+ oldLineNumber: oldCounter,
185
+ newLineNumber: newCounter,
186
+ });
187
+ }
188
+ if (wantSplit) {
189
+ splitRows.push({
190
+ left: { type: 'context', text, lineNumber: oldCounter },
191
+ right: { type: 'context', text, lineNumber: newCounter },
192
+ });
193
+ }
169
194
  oldCounter++;
170
195
  newCounter++;
171
196
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Covers `readSubagentProgressFrame`, the one reader every subagent-progress
8
+ * consumer goes through (sidebar roster, transcript hook, server liveness).
9
+ *
10
+ * The wrapper shape is the whole point: omp's `subagent_progress` frame nests
11
+ * the child's `AgentProgress` under `payload.progress` and puts NO `id` on the
12
+ * wrapper, so a reader that parses the wrapper alone yields an id-less object
13
+ * with none of the reported fields — silently, because every field is
14
+ * optional. The payload below is a verbatim frame captured from omp 18.3.2.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { parseSubagentProgress, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
20
+
21
+ /** One `subagent_progress` frame as omp emits it (trimmed to the read fields). */
22
+ const REAL_FRAME = {
23
+ index: 0,
24
+ agent: 'scout',
25
+ agentSource: 'user',
26
+ task: 'Complete assignment thoroughly:\n\n# Target\nCount files.',
27
+ parentToolCallId: 'call_00_LsrwnITatwhVYX5NElPf2544',
28
+ detached: true,
29
+ assignment: '# Target\nCount files.',
30
+ sessionFile: '/tmp/sessions/parent/CountTsx.jsonl',
31
+ progress: {
32
+ index: 0,
33
+ id: 'CountTsx',
34
+ agent: 'scout',
35
+ agentSource: 'user',
36
+ status: 'running',
37
+ task: 'Complete assignment thoroughly:\n\n# Target\nCount files.',
38
+ recentTools: [],
39
+ recentOutput: [],
40
+ toolCount: 3,
41
+ requests: 2,
42
+ tokens: 10400,
43
+ cost: 0.003,
44
+ durationMs: 8700,
45
+ modelRole: 'smol',
46
+ resolvedModelIdentity: 'kenari/deepseek-v4-flash',
47
+ resolvedThinkingLevel: 'low',
48
+ resolvedModel: 'kenari/deepseek-v4-flash:low',
49
+ resolvedModelIsFallback: false,
50
+ },
51
+ };
52
+
53
+ describe('readSubagentProgressFrame', () => {
54
+ test('unwraps the nested AgentProgress and keeps the fields it reports', () => {
55
+ const progress = readSubagentProgressFrame(REAL_FRAME);
56
+ expect(progress?.id).toBe('CountTsx');
57
+ expect(progress?.resolvedModel).toBe('kenari/deepseek-v4-flash:low');
58
+ expect(progress?.resolvedThinkingLevel).toBe('low');
59
+ expect(progress?.status).toBe('running');
60
+ expect(progress?.tokens).toBe(10400);
61
+ });
62
+
63
+ test('reads a bare AgentProgress payload (snapshot field, not a frame)', () => {
64
+ const progress = readSubagentProgressFrame(REAL_FRAME.progress);
65
+ expect(progress?.id).toBe('CountTsx');
66
+ expect(progress?.resolvedThinkingLevel).toBe('low');
67
+ });
68
+
69
+ test('keeps the wrapper id when the nested snapshot omits it', () => {
70
+ const progress = readSubagentProgressFrame({ id: 'Wrapped', progress: { status: 'running' } });
71
+ expect(progress?.id).toBe('Wrapped');
72
+ expect(progress?.status).toBe('running');
73
+ });
74
+
75
+ test('returns undefined for a payload carrying no progress fields at all', () => {
76
+ expect(readSubagentProgressFrame({ event: { type: 'message_end' } })).toBeUndefined();
77
+ expect(readSubagentProgressFrame(null)).toBeUndefined();
78
+ expect(readSubagentProgressFrame('nonsense')).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe('parseSubagentProgress', () => {
83
+ test('carries the resolved thinking level and drops a non-string one', () => {
84
+ expect(parseSubagentProgress({ resolvedThinkingLevel: 'max' })?.resolvedThinkingLevel).toBe('max');
85
+ expect(parseSubagentProgress({ resolvedThinkingLevel: null })?.resolvedThinkingLevel).toBeUndefined();
86
+ expect(parseSubagentProgress({ resolvedThinkingLevel: 3 })?.resolvedThinkingLevel).toBeUndefined();
87
+ });
88
+ });
@@ -81,6 +81,8 @@ export function parseSubagentProgress(value: unknown): SubagentProgress | undefi
81
81
  if (modelRole !== undefined) out.modelRole = modelRole;
82
82
  const resolvedModel = asString(value.resolvedModel);
83
83
  if (resolvedModel !== undefined) out.resolvedModel = resolvedModel;
84
+ const resolvedThinkingLevel = asString(value.resolvedThinkingLevel);
85
+ if (resolvedThinkingLevel !== undefined) out.resolvedThinkingLevel = resolvedThinkingLevel;
84
86
  if (typeof value.resolvedModelIsFallback === 'boolean') out.resolvedModelIsFallback = value.resolvedModelIsFallback;
85
87
  if (isRecord(value.retryState)) {
86
88
  const attempt = asNumber(value.retryState.attempt);
@@ -180,6 +182,21 @@ export function parseSubagentLifecycle(value: unknown): SubagentInfo | undefined
180
182
  return info;
181
183
  }
182
184
 
185
+ /**
186
+ * Read a progress frame's payload: `SubagentProgressPayload` nests the
187
+ * AgentProgress it reports under `progress`, while a bare AgentProgress (and a
188
+ * `get_subagents` snapshot's own field) is the object itself. Both shapes reach
189
+ * the same listeners, so unwrap here rather than at each call site — the outer
190
+ * wrapper's `id`/`index` fill in what the nested snapshot omits.
191
+ */
192
+ export function readSubagentProgressFrame(payload: unknown): SubagentProgress | undefined {
193
+ const direct = parseSubagentProgress(payload);
194
+ if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
195
+ const nested = parseSubagentProgress(payload.progress);
196
+ if (!nested) return direct;
197
+ return { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index };
198
+ }
199
+
183
200
  /** Extract a compact live-activity entry from a subagent_event payload. */
184
201
  export function parseSubagentActivityEvent(value: unknown): SubagentActivityEvent | null {
185
202
  if (!isRecord(value)) return null;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Covers the subagent transcript conversion's thinking-level stamping.
8
+ *
9
+ * A subagent transcript records its level as a separate `thinking_level_change`
10
+ * entry, never as a per-message field, so `convertMessages` has to walk the
11
+ * records and stamp the level in effect — otherwise the footer has no level to
12
+ * show and drops the segment entirely. The child's own level is the only
13
+ * correct one: a subagent routinely runs at a different level than the chat
14
+ * that spawned it, so borrowing the parent's would mislabel every row.
15
+ *
16
+ * Also pins the non-message entry handling the walk sits beside: a change
17
+ * record must be consumed, not converted into a row.
18
+ */
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+
22
+ import { convertMessages, mergeMessages } from '@/shared/lib/omp/subagent/transcript-client';
23
+ import type { ChatMessageData } from '@/shared/types';
24
+
25
+ /** An assistant message entry carrying text, as omp writes it to the JSONL. */
26
+ function assistantEntry(id: string, text: string): Record<string, unknown> {
27
+ return {
28
+ type: 'message',
29
+ id,
30
+ timestamp: 1790403839189,
31
+ message: {
32
+ role: 'assistant',
33
+ model: 'deepseek-v4-flash',
34
+ provider: 'kenari',
35
+ content: [{ type: 'text', text }],
36
+ },
37
+ };
38
+ }
39
+
40
+ function changeEntry(level: unknown): Record<string, unknown> {
41
+ return { type: 'thinking_level_change', id: `chg-${String(level)}`, thinkingLevel: level, configured: null };
42
+ }
43
+
44
+ describe('convertMessages thinking-level stamping', () => {
45
+ test('stamps the level in effect onto assistant rows', () => {
46
+ const rows = convertMessages([changeEntry('low'), assistantEntry('a1', 'first'), assistantEntry('a2', 'second')], false);
47
+ expect(rows.map((row) => row.thinkingLevel)).toEqual(['low', 'low']);
48
+ });
49
+
50
+ test('follows a mid-transcript change, so each turn keeps the level that served it', () => {
51
+ const rows = convertMessages([
52
+ changeEntry('low'),
53
+ assistantEntry('a1', 'first'),
54
+ changeEntry('high'),
55
+ assistantEntry('a2', 'second'),
56
+ ], false);
57
+ expect(rows[0].thinkingLevel).toBe('low');
58
+ expect(rows[1].thinkingLevel).toBe('high');
59
+ });
60
+
61
+ test('normalizes a null level record to "off" rather than dropping the segment', () => {
62
+ const rows = convertMessages([changeEntry(null), assistantEntry('a1', 'first')], false);
63
+ expect(rows[0].thinkingLevel).toBe('off');
64
+ });
65
+
66
+ test('leaves user rows unstamped and the level absent before any record', () => {
67
+ const rows = convertMessages([
68
+ { type: 'message', id: 'u1', message: { role: 'user', content: 'hi' } },
69
+ assistantEntry('a1', 'answer'),
70
+ changeEntry('max'),
71
+ assistantEntry('a2', 'later'),
72
+ ], false);
73
+ expect(rows[0].role).toBe('user');
74
+ expect(rows[0].thinkingLevel).toBeUndefined();
75
+ expect(rows[1].thinkingLevel).toBeUndefined();
76
+ expect(rows[2].thinkingLevel).toBe('max');
77
+ });
78
+
79
+ test('consumes the change record instead of turning it into a row', () => {
80
+ const rows = convertMessages([changeEntry('low')], false);
81
+ expect(rows).toHaveLength(0);
82
+ });
83
+ });
84
+
85
+ describe('mergeMessages', () => {
86
+ test('replaces a streamed row in place so its stamped level survives finalization', () => {
87
+ const streamed = convertMessages([changeEntry('low'), assistantEntry('a1', 'partial')], true);
88
+ const final = convertMessages([changeEntry('low'), assistantEntry('a1', 'complete answer')], false);
89
+ const merged = mergeMessages(streamed, final);
90
+ expect(merged).toHaveLength(1);
91
+ expect(merged[0].content).toBe('complete answer');
92
+ expect(merged[0].thinkingLevel).toBe('low');
93
+ });
94
+
95
+ test('appends a row whose id is new', () => {
96
+ const prev: ChatMessageData[] = convertMessages([changeEntry('low'), assistantEntry('a1', 'first')], false);
97
+ const merged = mergeMessages(prev, convertMessages([changeEntry('low'), assistantEntry('a2', 'second')], false));
98
+ expect(merged.map((row) => row.id)).toEqual(['a1', 'a2']);
99
+ });
100
+ });
@@ -13,6 +13,7 @@
13
13
  import type { ChatMessageData, SubagentMessagesPage } from '@/shared/types';
14
14
  import { toChatMessage } from '@/shared/lib/omp/session/mapper';
15
15
  import { extractText } from '@/shared/lib/omp/session/parse-message-blocks';
16
+ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
16
17
  import { isRecord } from '@/shared/lib/util/guards';
17
18
 
18
19
  /** Normalize one wire message: the RPC may hand back bare omp AgentMessages
@@ -29,9 +30,20 @@ function normalizeRawMessage(value: unknown): Record<string, unknown> | null {
29
30
 
30
31
  export function convertMessages(raw: unknown[], streaming: boolean): ChatMessageData[] {
31
32
  const out: ChatMessageData[] = [];
33
+ // omp records the thinking level as its own `thinking_level_change` entry
34
+ // (session state), not as a per-message field. Walk the records in order and
35
+ // stamp the level in effect onto each non-user turn, so a subagent footer
36
+ // shows the level that served that turn — the child's own level, never the
37
+ // parent session's. Mirrors the server's main-session loader
38
+ // (`loadSessionMessages`).
39
+ let currentLevel: string | undefined;
32
40
  for (const item of raw) {
33
41
  if (isRecord(item)) {
34
42
  const entryType = typeof item.type === 'string' ? item.type : '';
43
+ if (entryType === 'thinking_level_change') {
44
+ currentLevel = normalizeThinkingLevel(item.thinkingLevel);
45
+ continue;
46
+ }
35
47
  if (entryType === 'custom' || entryType === 'custom_message') {
36
48
  const customType = typeof item.customType === 'string' ? item.customType : '';
37
49
  if (customType === 'session_exit') {
@@ -77,7 +89,11 @@ export function convertMessages(raw: unknown[], streaming: boolean): ChatMessage
77
89
  }
78
90
 
79
91
  const converted = toChatMessage(record, streaming);
80
- if (converted) out.push(converted);
92
+ if (!converted) continue;
93
+ if (currentLevel !== undefined && converted.role !== 'user') {
94
+ converted.thinkingLevel = currentLevel;
95
+ }
96
+ out.push(converted);
81
97
  }
82
98
  return out;
83
99
  }
@@ -49,6 +49,12 @@ export interface SubagentProgress {
49
49
  modelOverride?: string | string[];
50
50
  modelRole?: string;
51
51
  resolvedModel?: string;
52
+ /**
53
+ * Thinking level the child's own model resolved to. Never inferred from the
54
+ * parent session: the child may run at a different level than the chat that
55
+ * spawned it, so the footer must read this one or show nothing.
56
+ */
57
+ resolvedThinkingLevel?: string;
52
58
  resolvedModelIsFallback?: boolean;
53
59
  retryState?: SubagentRetryState;
54
60
  retryFailure?: { attempt: number; errorMessage: string };