@love-moon/tui-driver 0.2.11 → 0.2.13

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 (73) hide show
  1. package/dist/driver/TuiDriver.d.ts +73 -0
  2. package/dist/driver/TuiDriver.d.ts.map +1 -1
  3. package/dist/driver/TuiDriver.js +1122 -42
  4. package/dist/driver/TuiDriver.js.map +1 -1
  5. package/dist/driver/TuiProfile.d.ts +2 -0
  6. package/dist/driver/TuiProfile.d.ts.map +1 -1
  7. package/dist/driver/TuiProfile.js.map +1 -1
  8. package/dist/driver/behavior/claude.behavior.d.ts +4 -0
  9. package/dist/driver/behavior/claude.behavior.d.ts.map +1 -0
  10. package/dist/driver/behavior/claude.behavior.js +48 -0
  11. package/dist/driver/behavior/claude.behavior.js.map +1 -0
  12. package/dist/driver/behavior/copilot.behavior.d.ts +4 -0
  13. package/dist/driver/behavior/copilot.behavior.d.ts.map +1 -0
  14. package/dist/driver/behavior/copilot.behavior.js +52 -0
  15. package/dist/driver/behavior/copilot.behavior.js.map +1 -0
  16. package/dist/driver/behavior/default.behavior.d.ts +4 -0
  17. package/dist/driver/behavior/default.behavior.d.ts.map +1 -0
  18. package/dist/driver/behavior/default.behavior.js +13 -0
  19. package/dist/driver/behavior/default.behavior.js.map +1 -0
  20. package/dist/driver/behavior/index.d.ts +5 -0
  21. package/dist/driver/behavior/index.d.ts.map +1 -0
  22. package/dist/driver/behavior/index.js +10 -0
  23. package/dist/driver/behavior/index.js.map +1 -0
  24. package/dist/driver/behavior/types.d.ts +57 -0
  25. package/dist/driver/behavior/types.d.ts.map +1 -0
  26. package/dist/driver/behavior/types.js +3 -0
  27. package/dist/driver/behavior/types.js.map +1 -0
  28. package/dist/driver/index.d.ts +4 -1
  29. package/dist/driver/index.d.ts.map +1 -1
  30. package/dist/driver/index.js +5 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -1
  33. package/dist/driver/profiles/claudeCode.profile.js +7 -3
  34. package/dist/driver/profiles/claudeCode.profile.js.map +1 -1
  35. package/dist/driver/profiles/copilot.profile.d.ts.map +1 -1
  36. package/dist/driver/profiles/copilot.profile.js +4 -0
  37. package/dist/driver/profiles/copilot.profile.js.map +1 -1
  38. package/dist/extract/OutputExtractor.d.ts +16 -0
  39. package/dist/extract/OutputExtractor.d.ts.map +1 -1
  40. package/dist/extract/OutputExtractor.js +113 -5
  41. package/dist/extract/OutputExtractor.js.map +1 -1
  42. package/dist/index.d.ts +2 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pty/PtySession.d.ts +1 -0
  47. package/dist/pty/PtySession.d.ts.map +1 -1
  48. package/dist/pty/PtySession.js +9 -0
  49. package/dist/pty/PtySession.js.map +1 -1
  50. package/docs/how-to-add-a-new-backend.md +212 -0
  51. package/package.json +1 -1
  52. package/src/driver/TuiDriver.ts +1332 -45
  53. package/src/driver/TuiProfile.ts +3 -0
  54. package/src/driver/behavior/claude.behavior.ts +54 -0
  55. package/src/driver/behavior/copilot.behavior.ts +63 -0
  56. package/src/driver/behavior/default.behavior.ts +12 -0
  57. package/src/driver/behavior/index.ts +14 -0
  58. package/src/driver/behavior/types.ts +64 -0
  59. package/src/driver/index.ts +20 -1
  60. package/src/driver/profiles/claudeCode.profile.ts +7 -3
  61. package/src/driver/profiles/copilot.profile.ts +4 -0
  62. package/src/extract/OutputExtractor.ts +145 -5
  63. package/src/index.ts +15 -0
  64. package/src/pty/PtySession.ts +10 -0
  65. package/test/claude-profile.test.ts +41 -0
  66. package/test/claude-signals.test.ts +80 -0
  67. package/test/codex-session-discovery.test.ts +101 -0
  68. package/test/copilot-profile.test.ts +12 -0
  69. package/test/copilot-signals.test.ts +70 -0
  70. package/test/output-extractor.test.ts +79 -0
  71. package/test/session-file-extraction.test.ts +257 -0
  72. package/test/stream-detection.test.ts +28 -0
  73. package/test/timeout-resolution.test.ts +37 -0
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { codexProfile } from "../src/driver/profiles/codex.profile.js";
4
+
5
+ describe("codex session discovery", () => {
6
+ it("returns session info when current cwd has a matching codex thread", async () => {
7
+ const rolloutPath = "/tmp/rollout-current.jsonl";
8
+ const driver = new TuiDriver({
9
+ profile: codexProfile,
10
+ cwd: "/tmp/current-workspace",
11
+ });
12
+
13
+ (driver as any).pathExists = vi.fn(async (filePath: string) => {
14
+ return filePath.endsWith("state_5.sqlite") || filePath === rolloutPath;
15
+ });
16
+ (driver as any).querySqliteRow = vi.fn(async () => `session-current|${rolloutPath}`);
17
+
18
+ const detected = await (driver as any).detectCodexSessionInfo();
19
+ expect(detected).toEqual({
20
+ backend: "codex",
21
+ sessionId: "session-current",
22
+ sessionFilePath: rolloutPath,
23
+ });
24
+ });
25
+
26
+ it("does not fall back to global latest codex thread when cwd has no match", async () => {
27
+ const driver = new TuiDriver({
28
+ profile: codexProfile,
29
+ cwd: "/tmp/task-workspace",
30
+ });
31
+
32
+ (driver as any).pathExists = vi.fn(async (filePath: string) => filePath.endsWith("state_5.sqlite"));
33
+ const querySqliteRow = vi.fn(async () => null);
34
+ (driver as any).querySqliteRow = querySqliteRow;
35
+
36
+ const detected = await (driver as any).detectCodexSessionInfo();
37
+ expect(detected).toBeNull();
38
+ expect(querySqliteRow).toHaveBeenCalledTimes(1);
39
+ expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("cwd='/tmp/task-workspace'");
40
+ });
41
+
42
+ it("prefers pinned session id before cwd lookup", async () => {
43
+ const pinnedPath = "/tmp/rollout-pinned.jsonl";
44
+ const driver = new TuiDriver({
45
+ profile: codexProfile,
46
+ cwd: "/tmp/task-workspace",
47
+ });
48
+
49
+ (driver as any).lastSessionInfo = {
50
+ backend: "codex",
51
+ sessionId: "session-pinned",
52
+ sessionFilePath: "/tmp/old.jsonl",
53
+ };
54
+ (driver as any).pathExists = vi.fn(async (filePath: string) => {
55
+ return filePath.endsWith("state_5.sqlite") || filePath === pinnedPath;
56
+ });
57
+ const querySqliteRow = vi.fn(async () => `session-pinned|${pinnedPath}`);
58
+ (driver as any).querySqliteRow = querySqliteRow;
59
+
60
+ const detected = await (driver as any).detectCodexSessionInfo();
61
+ expect(detected).toEqual({
62
+ backend: "codex",
63
+ sessionId: "session-pinned",
64
+ sessionFilePath: pinnedPath,
65
+ });
66
+ expect(querySqliteRow).toHaveBeenCalledTimes(1);
67
+ expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("id='session-pinned'");
68
+ });
69
+
70
+ it("falls back to cwd lookup when pinned session id is missing", async () => {
71
+ const cwdPath = "/tmp/rollout-cwd.jsonl";
72
+ const driver = new TuiDriver({
73
+ profile: codexProfile,
74
+ cwd: "/tmp/task-workspace",
75
+ });
76
+
77
+ (driver as any).lastSessionInfo = {
78
+ backend: "codex",
79
+ sessionId: "session-missing",
80
+ sessionFilePath: "/tmp/missing.jsonl",
81
+ };
82
+ (driver as any).pathExists = vi.fn(async (filePath: string) => {
83
+ return filePath.endsWith("state_5.sqlite") || filePath === cwdPath;
84
+ });
85
+ const querySqliteRow = vi
86
+ .fn(async () => null)
87
+ .mockImplementationOnce(async () => null)
88
+ .mockImplementationOnce(async () => `session-cwd|${cwdPath}`);
89
+ (driver as any).querySqliteRow = querySqliteRow;
90
+
91
+ const detected = await (driver as any).detectCodexSessionInfo();
92
+ expect(detected).toEqual({
93
+ backend: "codex",
94
+ sessionId: "session-cwd",
95
+ sessionFilePath: cwdPath,
96
+ });
97
+ expect(querySqliteRow).toHaveBeenCalledTimes(2);
98
+ expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("id='session-missing'");
99
+ expect(String(querySqliteRow.mock.calls[1]?.[1] || "")).toContain("cwd='/tmp/task-workspace'");
100
+ });
101
+ });
@@ -29,6 +29,18 @@ describe("copilot profile ready anchors", () => {
29
29
  expect(matcher(snapshot)).toBe(true);
30
30
  });
31
31
 
32
+ it("matches ready prompt even when remaining quota line is not visible", () => {
33
+ const snapshot = createSnapshot([
34
+ " ~/ws/conductor[⎇ main] claude-opus-4.6 (high) (3x)",
35
+ "────────────────────────────────────────────────────────────────────────────────",
36
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
37
+ "────────────────────────────────────────────────────────────────────────────────",
38
+ ].join("\n"));
39
+
40
+ const matcher = Matchers.anyOf(copilotProfile.anchors.ready);
41
+ expect(matcher(snapshot)).toBe(true);
42
+ });
43
+
32
44
  it("does not match ready while thinking is present", () => {
33
45
  const snapshot = createSnapshot([
34
46
  "∙ Thinking (Esc to cancel)",
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { copilotProfile } from "../src/driver/profiles/copilot.profile.js";
4
+ import { ScreenSnapshot } from "../src/term/ScreenSnapshot.js";
5
+
6
+ function createSnapshot(text: string): ScreenSnapshot {
7
+ return new ScreenSnapshot({
8
+ viewportText: text,
9
+ scrollbackText: text,
10
+ cursor: { x: 0, y: 0 },
11
+ hash: ScreenSnapshot.computeHash(text),
12
+ timestamp: Date.now(),
13
+ cols: 120,
14
+ rows: 40,
15
+ });
16
+ }
17
+
18
+ describe("copilot getSignals current-turn scope", () => {
19
+ it("ignores history reply lines before the latest user message", () => {
20
+ const driver = new TuiDriver({ profile: copilotProfile });
21
+ const snapshot = createSnapshot([
22
+ "You said: old question",
23
+ "Copilot said: old answer",
24
+ "You said: new question",
25
+ "◉ Thinking (Esc to cancel)",
26
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
27
+ ].join("\n"));
28
+
29
+ const signals = driver.getSignals(snapshot);
30
+ expect(signals.replyText).toBeUndefined();
31
+ expect(signals.statusLine).toBe("◉ Thinking (Esc to cancel)");
32
+
33
+ driver.kill();
34
+ });
35
+
36
+ it("captures reply from the latest turn when using You said markers", () => {
37
+ const driver = new TuiDriver({ profile: copilotProfile });
38
+ const snapshot = createSnapshot([
39
+ "You said: old question",
40
+ "Copilot said: old answer",
41
+ "You said: new question",
42
+ "Copilot said: new answer",
43
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
44
+ ].join("\n"));
45
+
46
+ const signals = driver.getSignals(snapshot);
47
+ expect(signals.replyText).toBe("Copilot said: new answer");
48
+ expect(signals.replyBlocks).toEqual(["Copilot said: new answer"]);
49
+
50
+ driver.kill();
51
+ });
52
+
53
+ it("captures reply from the latest turn in non-screen-reader mode", () => {
54
+ const driver = new TuiDriver({ profile: copilotProfile });
55
+ const snapshot = createSnapshot([
56
+ "❯ old question",
57
+ "● old answer",
58
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
59
+ "❯ new question",
60
+ "● new answer",
61
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
62
+ ].join("\n"));
63
+
64
+ const signals = driver.getSignals(snapshot);
65
+ expect(signals.replyText).toBe("● new answer");
66
+ expect(signals.replyBlocks).toEqual(["● new answer"]);
67
+
68
+ driver.kill();
69
+ });
70
+ });
@@ -46,4 +46,83 @@ describe("OutputExtractor", () => {
46
46
  expect(relaxed).toContain("2026年的春天");
47
47
  expect(relaxed).toContain("当当和七喜");
48
48
  });
49
+
50
+ it("parses latest reply block and ignores content after prompt", () => {
51
+ const extraction: TuiExtraction = {
52
+ mode: "diff-scrollback",
53
+ includeLinePatterns: [/^\s*•\s*(?!Working|Thinking).+/m],
54
+ stopLinePatterns: [/^\s*›\s*/m],
55
+ stripPatterns: [/^>\s*/gm, /^\s*›\s*/gm, /^\s*•\s*/gm],
56
+ stripEcho: true,
57
+ bottomUiLines: 2,
58
+ };
59
+
60
+ const before = createSnapshot([
61
+ "model: gpt-5.3-codex",
62
+ "› ",
63
+ ].join("\n"));
64
+
65
+ const after = createSnapshot([
66
+ "model: gpt-5.3-codex",
67
+ "• Here is the answer",
68
+ "with two lines",
69
+ "› ",
70
+ "• custom statusline should be ignored",
71
+ ].join("\n"));
72
+
73
+ const extracted = new OutputExtractor(extraction).extract(before, after).text;
74
+ expect(extracted).toBe("Here is the answer\nwith two lines");
75
+ });
76
+
77
+ it("returns empty when no reply block exists before prompt", () => {
78
+ const extraction: TuiExtraction = {
79
+ mode: "diff-scrollback",
80
+ includeLinePatterns: [/^\s*•\s*(?!Working|Thinking).+/m],
81
+ stopLinePatterns: [/^\s*›\s*/m],
82
+ stripPatterns: [/^\s*›\s*/gm, /^\s*•\s*/gm],
83
+ stripEcho: true,
84
+ bottomUiLines: 2,
85
+ };
86
+
87
+ const before = createSnapshot([
88
+ "model: gpt-5.3-codex",
89
+ "› ",
90
+ ].join("\n"));
91
+
92
+ const after = createSnapshot([
93
+ "model: gpt-5.3-codex",
94
+ "› ",
95
+ "• custom statusline should be ignored",
96
+ ].join("\n"));
97
+
98
+ const extracted = new OutputExtractor(extraction).extract(before, after).text;
99
+ expect(extracted).toBe("");
100
+ });
101
+
102
+ it("does not reuse previous reply block when only a new user line is added", () => {
103
+ const extraction: TuiExtraction = {
104
+ mode: "diff-scrollback",
105
+ includeLinePatterns: [/^Copilot said:\s*/im],
106
+ stopLinePatterns: [/^❯\s*/m],
107
+ stripPatterns: [/^Copilot said:\s*/gim, /^You said:\s*/gim, /^❯\s*/gm],
108
+ stripEcho: true,
109
+ bottomUiLines: 2,
110
+ };
111
+
112
+ const before = createSnapshot([
113
+ "You said: old question",
114
+ "Copilot said: old answer",
115
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
116
+ ].join("\n"));
117
+
118
+ const after = createSnapshot([
119
+ "You said: old question",
120
+ "Copilot said: old answer",
121
+ "You said: new question",
122
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
123
+ ].join("\n"));
124
+
125
+ const extracted = new OutputExtractor(extraction).extract(before, after).text;
126
+ expect(extracted).toBe("");
127
+ });
49
128
  });
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
4
+ import { codexProfile } from "../src/driver/profiles/codex.profile.js";
5
+ import { copilotProfile } from "../src/driver/profiles/copilot.profile.js";
6
+
7
+ describe("session file reply extraction", () => {
8
+ it("prefers codex task_complete last_agent_message over intermediate assistant text", () => {
9
+ const driver = new TuiDriver({ profile: codexProfile });
10
+ const lines = [
11
+ JSON.stringify({
12
+ type: "response_item",
13
+ payload: {
14
+ type: "message",
15
+ role: "assistant",
16
+ content: [{ type: "output_text", text: "我先快速检查一下" }],
17
+ },
18
+ }),
19
+ JSON.stringify({
20
+ type: "event_msg",
21
+ payload: {
22
+ type: "task_complete",
23
+ last_agent_message: "最终结论:readback 被主线程排队阻塞",
24
+ },
25
+ }),
26
+ ];
27
+
28
+ const extracted = (driver as any).extractCodexTaskCompleteMessageFromJsonLines(lines);
29
+ expect(extracted).toBe("最终结论:readback 被主线程排队阻塞");
30
+ driver.kill();
31
+ });
32
+
33
+ it("detects codex task_complete completion marker from jsonl lines", () => {
34
+ const driver = new TuiDriver({ profile: codexProfile });
35
+ const lines = [
36
+ JSON.stringify({
37
+ type: "response_item",
38
+ payload: {
39
+ type: "message",
40
+ role: "assistant",
41
+ content: [{ type: "output_text", text: "intermediate" }],
42
+ },
43
+ }),
44
+ JSON.stringify({
45
+ type: "event_msg",
46
+ payload: {
47
+ type: "task_complete",
48
+ last_agent_message: "final",
49
+ },
50
+ }),
51
+ ];
52
+
53
+ const detected = (driver as any).hasCodexTaskCompleteFromJsonLines(lines);
54
+ expect(detected).toBe(true);
55
+ driver.kill();
56
+ });
57
+
58
+ it("detects copilot assistant.turn_end completion marker from jsonl lines", () => {
59
+ const driver = new TuiDriver({ profile: codexProfile });
60
+ const lines = [
61
+ JSON.stringify({
62
+ type: "assistant.message",
63
+ data: {
64
+ content: "先看下代码",
65
+ },
66
+ }),
67
+ JSON.stringify({
68
+ type: "assistant.turn_end",
69
+ data: {
70
+ turnId: "12",
71
+ },
72
+ }),
73
+ ];
74
+
75
+ const detected = (driver as any).hasCopilotTurnEndFromJsonLines(lines);
76
+ expect(detected).toBe(true);
77
+ driver.kill();
78
+ });
79
+
80
+ it("extracts the latest codex assistant message from jsonl lines", () => {
81
+ const driver = new TuiDriver({ profile: codexProfile });
82
+ const lines = [
83
+ JSON.stringify({
84
+ type: "response_item",
85
+ payload: {
86
+ type: "message",
87
+ role: "assistant",
88
+ content: [{ type: "output_text", text: "first answer" }],
89
+ },
90
+ }),
91
+ JSON.stringify({
92
+ type: "response_item",
93
+ payload: {
94
+ type: "message",
95
+ role: "assistant",
96
+ content: [{ type: "output_text", text: "second answer" }],
97
+ },
98
+ }),
99
+ ];
100
+
101
+ const extracted = (driver as any).extractAssistantReplyFromJsonLines(lines, "codex");
102
+ expect(extracted).toBe("second answer");
103
+ driver.kill();
104
+ });
105
+
106
+ it("extracts the latest claude assistant text block", () => {
107
+ const driver = new TuiDriver({ profile: codexProfile });
108
+ const lines = [
109
+ JSON.stringify({
110
+ type: "assistant",
111
+ message: {
112
+ role: "assistant",
113
+ content: [{ type: "tool_use", id: "x", name: "Bash", input: {} }],
114
+ },
115
+ }),
116
+ JSON.stringify({
117
+ type: "assistant",
118
+ message: {
119
+ role: "assistant",
120
+ content: [{ type: "text", text: "claude final answer" }],
121
+ },
122
+ }),
123
+ ];
124
+
125
+ const extracted = (driver as any).extractAssistantReplyFromJsonLines(lines, "claude-code");
126
+ expect(extracted).toBe("claude final answer");
127
+ driver.kill();
128
+ });
129
+
130
+ it("ignores empty copilot wrapper messages and keeps text replies", () => {
131
+ const driver = new TuiDriver({ profile: codexProfile });
132
+ const lines = [
133
+ JSON.stringify({
134
+ type: "assistant.message",
135
+ data: { content: "", toolRequests: [{ name: "report_intent" }] },
136
+ }),
137
+ JSON.stringify({
138
+ type: "assistant.message",
139
+ data: { content: "copilot final answer", toolRequests: [] },
140
+ }),
141
+ ];
142
+
143
+ const extracted = (driver as any).extractAssistantReplyFromJsonLines(lines, "copilot");
144
+ expect(extracted).toBe("copilot final answer");
145
+ driver.kill();
146
+ });
147
+
148
+ it("builds codex resume args for restart and strips stale resume tokens", () => {
149
+ const driver = new TuiDriver({
150
+ profile: {
151
+ ...codexProfile,
152
+ args: ["--sandbox", "workspace-write", "resume", "old-session"],
153
+ },
154
+ });
155
+ const restartArgs = (driver as any).resolveRestartArgs("new-session");
156
+ expect(restartArgs).toEqual(["--sandbox", "workspace-write", "resume", "new-session"]);
157
+ driver.kill();
158
+ });
159
+
160
+ it("builds claude and copilot resume args for restart", () => {
161
+ const claudeDriver = new TuiDriver({
162
+ profile: {
163
+ ...claudeCodeProfile,
164
+ args: ["--resume", "old-session", "--verbose"],
165
+ },
166
+ });
167
+ const claudeArgs = (claudeDriver as any).resolveRestartArgs("new-session");
168
+ expect(claudeArgs).toEqual(["--verbose", "--resume", "new-session"]);
169
+ claudeDriver.kill();
170
+
171
+ const copilotDriver = new TuiDriver({
172
+ profile: {
173
+ ...copilotProfile,
174
+ args: ["--resume=old-session", "--all-tools"],
175
+ },
176
+ });
177
+ const copilotArgs = (copilotDriver as any).resolveRestartArgs("new-session");
178
+ expect(copilotArgs).toEqual(["--all-tools", "--resume=new-session"]);
179
+ copilotDriver.kill();
180
+ });
181
+
182
+ it("extracts codex token/context usage percentages from session jsonl", () => {
183
+ const driver = new TuiDriver({ profile: codexProfile });
184
+ const lines = [
185
+ JSON.stringify({
186
+ type: "event_msg",
187
+ payload: {
188
+ type: "token_count",
189
+ rate_limits: {
190
+ secondary: {
191
+ used_percent: 26,
192
+ },
193
+ },
194
+ info: {
195
+ last_token_usage: {
196
+ input_tokens: 12920,
197
+ },
198
+ model_context_window: 258400,
199
+ },
200
+ },
201
+ }),
202
+ ];
203
+
204
+ const extracted = (driver as any).extractCodexUsageFromJsonLines(lines);
205
+ expect(extracted.tokenUsagePercent).toBe(26);
206
+ expect(extracted.contextUsagePercent).toBeCloseTo(5, 4);
207
+ driver.kill();
208
+ });
209
+
210
+ it("extracts copilot context usage percentage from compaction/session telemetry", () => {
211
+ const driver = new TuiDriver({ profile: codexProfile });
212
+ const lines = [
213
+ JSON.stringify({
214
+ type: "tool.execution_complete",
215
+ data: {
216
+ toolTelemetry: {
217
+ metrics: {
218
+ responseTokenLimit: 68000,
219
+ },
220
+ },
221
+ },
222
+ }),
223
+ JSON.stringify({
224
+ type: "session.compaction_complete",
225
+ data: {
226
+ preCompactionTokens: 34000,
227
+ },
228
+ }),
229
+ ];
230
+
231
+ const extracted = (driver as any).extractCopilotUsageFromJsonLines(lines);
232
+ expect(extracted.tokenUsagePercent).toBeUndefined();
233
+ expect(extracted.contextUsagePercent).toBe(50);
234
+ driver.kill();
235
+ });
236
+
237
+ it("keeps claude usage percentages undefined when no limits are available", () => {
238
+ const driver = new TuiDriver({ profile: codexProfile });
239
+ const lines = [
240
+ JSON.stringify({
241
+ type: "assistant",
242
+ message: {
243
+ role: "assistant",
244
+ usage: {
245
+ input_tokens: 20516,
246
+ output_tokens: 0,
247
+ },
248
+ },
249
+ }),
250
+ ];
251
+
252
+ const extracted = (driver as any).extractClaudeUsageFromJsonLines(lines);
253
+ expect(extracted.tokenUsagePercent).toBeUndefined();
254
+ expect(extracted.contextUsagePercent).toBeUndefined();
255
+ driver.kill();
256
+ });
257
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
4
+
5
+ describe("stream detection with repeated lines", () => {
6
+ it("treats repeated assistant lines in later turns as new reply start", () => {
7
+ const driver = new TuiDriver({ profile: claudeCodeProfile });
8
+ const before = [
9
+ "❯ first question",
10
+ "⏺ 2",
11
+ "❯ ",
12
+ "❯ hi, 1+1=?",
13
+ ].join("\n");
14
+ const after = [
15
+ "❯ first question",
16
+ "⏺ 2",
17
+ "❯ ",
18
+ "❯ hi, 1+1=?",
19
+ "⏺ 2",
20
+ "❯ ",
21
+ ].join("\n");
22
+
23
+ const matched = (driver as any).hasNewScrollbackPatternSince(before, after, [/^⏺\s/]);
24
+ expect(matched).toBe(true);
25
+
26
+ driver.kill();
27
+ });
28
+ });
@@ -0,0 +1,37 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
+ import { codexProfile } from "../src/driver/profiles/codex.profile.js";
4
+
5
+ const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
6
+
7
+ describe("timeout resolution", () => {
8
+ const originalMaxTimeout = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
9
+
10
+ afterEach(() => {
11
+ if (typeof originalMaxTimeout === "undefined") {
12
+ delete process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
13
+ } else {
14
+ process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS = originalMaxTimeout;
15
+ }
16
+ });
17
+
18
+ it("treats timeout=0 as max stage timeout", () => {
19
+ delete process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
20
+ const driver = new TuiDriver({ profile: codexProfile });
21
+ const resolved = (driver as any).resolveTimeout(0, 10_000);
22
+ expect(resolved).toBe(DEFAULT_STAGE_TIMEOUT_MAX_MS);
23
+ });
24
+
25
+ it("respects CONDUCTOR_TUI_MAX_TIMEOUT_MS when timeout=0", () => {
26
+ process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS = "600000";
27
+ const driver = new TuiDriver({ profile: codexProfile });
28
+ const resolved = (driver as any).resolveTimeout(0, 10_000);
29
+ expect(resolved).toBe(600_000);
30
+ });
31
+
32
+ it("keeps default timeout when configured timeout is undefined", () => {
33
+ const driver = new TuiDriver({ profile: codexProfile });
34
+ const resolved = (driver as any).resolveTimeout(undefined, 12_345);
35
+ expect(resolved).toBe(12_345);
36
+ });
37
+ });