@love-moon/tui-driver 0.2.13 → 0.2.14

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.
@@ -5,8 +5,13 @@ export {
5
5
  TuiScreenSignals,
6
6
  HealthStatus,
7
7
  HealthReason,
8
+ TuiSessionAssistantMessage,
9
+ TuiSessionAssistantMessageBatch,
10
+ } from "./TuiDriver.js";
11
+ export type {
12
+ TuiSessionInfo,
13
+ TuiSessionUsageSummary,
8
14
  } from "./TuiDriver.js";
9
- export type { TuiSessionInfo, TuiSessionUsageSummary } from "./TuiDriver.js";
10
15
  export { TuiProfile, TuiProfileName, TuiAnchors, TuiKeys, TuiExtraction, TuiSignals, createProfile } from "./TuiProfile.js";
11
16
  export { StateMachine, TuiState, StateTransition } from "./StateMachine.js";
12
17
  export { claudeCodeProfile, codexProfile, copilotProfile } from "./profiles/index.js";
@@ -19,18 +19,18 @@ export const copilotProfile: TuiProfile = createProfile({
19
19
 
20
20
  anchors: {
21
21
  ready: [
22
- /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)❯\s+)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/,
23
- /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/i,
24
- /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)❯\s+)[\s\S]*$/,
25
- /^(?![\s\S]*Thinking \(Esc to cancel)(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)[\s\S]*$/i,
22
+ /^(?![\s\S]*(?:^|\n)\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\))(?=[\s\S]*(?:^|\n)❯\s+)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/,
23
+ /^(?![\s\S]*(?:^|\n)\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\))(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)(?=[\s\S]*Remaining reqs\.:)[\s\S]*$/i,
24
+ /^(?![\s\S]*(?:^|\n)\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\))(?=[\s\S]*(?:^|\n)❯\s+)[\s\S]*$/,
25
+ /^(?![\s\S]*(?:^|\n)\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\))(?=[\s\S]*(?:^|\n)Start of Prompt Indicator\b)[\s\S]*$/i,
26
26
  ],
27
27
  trust: [
28
28
  /Remember screen reader mode/i,
29
29
  /Do you want to remember screen reader mode/i,
30
30
  ],
31
31
  busy: [
32
- /^\s*[∙◉◎]\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/m,
33
- /^\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/m,
32
+ /^\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/m,
33
+ /^\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/m,
34
34
  ],
35
35
  error: [
36
36
  /^Error:/m,
@@ -78,8 +78,8 @@ export const copilotProfile: TuiProfile = createProfile({
78
78
  /^\s*●\s*/gm,
79
79
  /^Copilot said:\s*/gim,
80
80
  /^You said:\s*/gim,
81
- /^\s*[∙◉◎]\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/gm,
82
- /^\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/gm,
81
+ /^\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/gm,
82
+ /^\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/gm,
83
83
  /^[-─]{10,}\s*$/gm,
84
84
  /^\s*shift\+tab switch mode.*$/gim,
85
85
  /^\s*Remaining reqs\.:.*$/gim,
@@ -95,8 +95,8 @@ export const copilotProfile: TuiProfile = createProfile({
95
95
  replyStart: [/^\s*●\s+/m, /^Copilot said:\s*/i],
96
96
  replyStop: [/^❯\s+/m, /^Start of Prompt Indicator\b/i],
97
97
  status: [
98
- /^\s*[∙◉◎]\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/m,
99
- /^\s*Thinking \(Esc to cancel(?:\s*·.*)?\)\s*$/m,
98
+ /^\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/m,
99
+ /^\s*.+\(Esc to cancel(?:\s*·.*)?\)\s*$/m,
100
100
  ],
101
101
  },
102
102
 
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export {
30
30
  TuiScreenSignals,
31
31
  HealthStatus,
32
32
  HealthReason,
33
+ TuiSessionAssistantMessage,
34
+ TuiSessionAssistantMessageBatch,
33
35
  defaultTuiDriverBehavior,
34
36
  claudeBehavior,
35
37
  copilotBehavior,
@@ -0,0 +1,134 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { TuiDriver } from "../src/driver/TuiDriver.js";
4
+ import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
5
+ import { copilotProfile } from "../src/driver/profiles/copilot.profile.js";
6
+
7
+ function createDirent(name: string, isDirectory: boolean) {
8
+ return {
9
+ name,
10
+ isDirectory: () => isDirectory,
11
+ isFile: () => !isDirectory,
12
+ };
13
+ }
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ describe("backend session discovery", () => {
20
+ it("captures claude baseline session ids from the current workspace", async () => {
21
+ const driver = new TuiDriver({
22
+ profile: claudeCodeProfile,
23
+ cwd: "/tmp/claude-workspace",
24
+ });
25
+
26
+ vi.spyOn(driver as any, "pathExists").mockResolvedValue(true);
27
+ vi.spyOn(fs, "readFile").mockResolvedValue(
28
+ JSON.stringify({
29
+ entries: [
30
+ {
31
+ sessionId: "session-existing",
32
+ fullPath: "/tmp/claude-existing.jsonl",
33
+ fileMtime: 200,
34
+ projectPath: "/tmp/claude-workspace",
35
+ },
36
+ {
37
+ sessionId: "session-other",
38
+ fullPath: "/tmp/claude-other.jsonl",
39
+ fileMtime: 100,
40
+ projectPath: "/tmp/claude-workspace",
41
+ },
42
+ ],
43
+ }) as any,
44
+ );
45
+ vi.spyOn(fs, "readdir").mockResolvedValue([] as any);
46
+
47
+ const baselineIds = await (driver as any).captureSessionBaseline();
48
+
49
+ expect(baselineIds).toEqual(["session-existing", "session-other"]);
50
+ driver.kill();
51
+ });
52
+
53
+ it("skips claude baseline sessions and picks the next fresh session", async () => {
54
+ const driver = new TuiDriver({
55
+ profile: claudeCodeProfile,
56
+ cwd: "/tmp/claude-workspace",
57
+ });
58
+ (driver as any).sessionDetectBaselineIds = ["session-existing"];
59
+
60
+ vi.spyOn(driver as any, "pathExists").mockResolvedValue(true);
61
+ vi.spyOn(fs, "readFile").mockResolvedValue(
62
+ JSON.stringify({
63
+ entries: [
64
+ {
65
+ sessionId: "session-existing",
66
+ fullPath: "/tmp/claude-existing.jsonl",
67
+ fileMtime: 200,
68
+ projectPath: "/tmp/claude-workspace",
69
+ },
70
+ {
71
+ sessionId: "session-new",
72
+ fullPath: "/tmp/claude-new.jsonl",
73
+ fileMtime: 100,
74
+ projectPath: "/tmp/claude-workspace",
75
+ },
76
+ ],
77
+ }) as any,
78
+ );
79
+ vi.spyOn(fs, "readdir").mockResolvedValue([] as any);
80
+
81
+ const detected = await (driver as any).detectClaudeSessionInfo();
82
+
83
+ expect(detected).toEqual({
84
+ backend: "claude-code",
85
+ sessionId: "session-new",
86
+ sessionFilePath: "/tmp/claude-new.jsonl",
87
+ });
88
+ driver.kill();
89
+ });
90
+
91
+ it("prefers pinned copilot session id before the latest workspace session", async () => {
92
+ const driver = new TuiDriver({
93
+ profile: copilotProfile,
94
+ cwd: "/tmp/copilot-workspace",
95
+ });
96
+ (driver as any).lastSessionInfo = {
97
+ backend: "copilot",
98
+ sessionId: "session-pinned",
99
+ sessionFilePath: "/tmp/pinned/events.jsonl",
100
+ };
101
+
102
+ vi.spyOn(driver as any, "pathExists").mockResolvedValue(true);
103
+ vi.spyOn(driver as any, "readWorkspaceYamlValue").mockImplementation(
104
+ async (filePath: string, key: string) => {
105
+ if (filePath.includes("/latest/")) {
106
+ return key === "cwd" ? "/tmp/copilot-workspace" : "session-latest";
107
+ }
108
+ if (filePath.includes("/pinned/")) {
109
+ return key === "cwd" ? "/tmp/copilot-workspace" : "session-pinned";
110
+ }
111
+ return "";
112
+ },
113
+ );
114
+ vi.spyOn(fs, "readdir").mockResolvedValue([
115
+ createDirent("latest", true),
116
+ createDirent("pinned", true),
117
+ ] as any);
118
+ vi.spyOn(fs, "stat").mockImplementation(async (filePath: any) => {
119
+ const target = String(filePath || "");
120
+ return {
121
+ mtimeMs: target.includes("/latest/") ? 300 : 100,
122
+ } as any;
123
+ });
124
+
125
+ const detected = await (driver as any).detectCopilotSessionInfo();
126
+
127
+ expect(detected).toEqual({
128
+ backend: "copilot",
129
+ sessionId: "session-pinned",
130
+ sessionFilePath: expect.stringContaining("/pinned/events.jsonl"),
131
+ });
132
+ driver.kill();
133
+ });
134
+ });
@@ -3,12 +3,14 @@ import { TuiDriver } from "../src/driver/TuiDriver.js";
3
3
  import { codexProfile } from "../src/driver/profiles/codex.profile.js";
4
4
 
5
5
  describe("codex session discovery", () => {
6
- it("returns session info when current cwd has a matching codex thread", async () => {
6
+ it("returns session info when current cwd has a new codex thread after boot", async () => {
7
7
  const rolloutPath = "/tmp/rollout-current.jsonl";
8
8
  const driver = new TuiDriver({
9
9
  profile: codexProfile,
10
10
  cwd: "/tmp/current-workspace",
11
11
  });
12
+ (driver as any).sessionDetectStartSec = 100;
13
+ (driver as any).sessionDetectBaselineIds = [];
12
14
 
13
15
  (driver as any).pathExists = vi.fn(async (filePath: string) => {
14
16
  return filePath.endsWith("state_5.sqlite") || filePath === rolloutPath;
@@ -28,6 +30,8 @@ describe("codex session discovery", () => {
28
30
  profile: codexProfile,
29
31
  cwd: "/tmp/task-workspace",
30
32
  });
33
+ (driver as any).sessionDetectStartSec = 100;
34
+ (driver as any).sessionDetectBaselineIds = [];
31
35
 
32
36
  (driver as any).pathExists = vi.fn(async (filePath: string) => filePath.endsWith("state_5.sqlite"));
33
37
  const querySqliteRow = vi.fn(async () => null);
@@ -36,6 +40,7 @@ describe("codex session discovery", () => {
36
40
  const detected = await (driver as any).detectCodexSessionInfo();
37
41
  expect(detected).toBeNull();
38
42
  expect(querySqliteRow).toHaveBeenCalledTimes(1);
43
+ expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("created_at >= 100");
39
44
  expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("cwd='/tmp/task-workspace'");
40
45
  });
41
46
 
@@ -45,6 +50,8 @@ describe("codex session discovery", () => {
45
50
  profile: codexProfile,
46
51
  cwd: "/tmp/task-workspace",
47
52
  });
53
+ (driver as any).sessionDetectStartSec = 100;
54
+ (driver as any).sessionDetectBaselineIds = [];
48
55
 
49
56
  (driver as any).lastSessionInfo = {
50
57
  backend: "codex",
@@ -67,12 +74,14 @@ describe("codex session discovery", () => {
67
74
  expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("id='session-pinned'");
68
75
  });
69
76
 
70
- it("falls back to cwd lookup when pinned session id is missing", async () => {
71
- const cwdPath = "/tmp/rollout-cwd.jsonl";
77
+ it("falls back to launch-window lookup when pinned session id is missing", async () => {
78
+ const launchPath = "/tmp/rollout-launch.jsonl";
72
79
  const driver = new TuiDriver({
73
80
  profile: codexProfile,
74
81
  cwd: "/tmp/task-workspace",
75
82
  });
83
+ (driver as any).sessionDetectStartSec = 100;
84
+ (driver as any).sessionDetectBaselineIds = ["session-existing"];
76
85
 
77
86
  (driver as any).lastSessionInfo = {
78
87
  backend: "codex",
@@ -80,22 +89,24 @@ describe("codex session discovery", () => {
80
89
  sessionFilePath: "/tmp/missing.jsonl",
81
90
  };
82
91
  (driver as any).pathExists = vi.fn(async (filePath: string) => {
83
- return filePath.endsWith("state_5.sqlite") || filePath === cwdPath;
92
+ return filePath.endsWith("state_5.sqlite") || filePath === launchPath;
84
93
  });
85
94
  const querySqliteRow = vi
86
95
  .fn(async () => null)
87
96
  .mockImplementationOnce(async () => null)
88
- .mockImplementationOnce(async () => `session-cwd|${cwdPath}`);
97
+ .mockImplementationOnce(async () => `session-new|${launchPath}`);
89
98
  (driver as any).querySqliteRow = querySqliteRow;
90
99
 
91
100
  const detected = await (driver as any).detectCodexSessionInfo();
92
101
  expect(detected).toEqual({
93
102
  backend: "codex",
94
- sessionId: "session-cwd",
95
- sessionFilePath: cwdPath,
103
+ sessionId: "session-new",
104
+ sessionFilePath: launchPath,
96
105
  });
97
106
  expect(querySqliteRow).toHaveBeenCalledTimes(2);
98
107
  expect(String(querySqliteRow.mock.calls[0]?.[1] || "")).toContain("id='session-missing'");
108
+ expect(String(querySqliteRow.mock.calls[1]?.[1] || "")).toContain("created_at >= 100");
99
109
  expect(String(querySqliteRow.mock.calls[1]?.[1] || "")).toContain("cwd='/tmp/task-workspace'");
110
+ expect(String(querySqliteRow.mock.calls[1]?.[1] || "")).toContain("id not in ('session-existing')");
100
111
  });
101
112
  });
@@ -68,6 +68,18 @@ describe("copilot profile busy and reply signals", () => {
68
68
  expect(statusMatcher(snapshot)).toBe(true);
69
69
  });
70
70
 
71
+ it("matches busy/status lines for non-thinking Esc-to-cancel states", () => {
72
+ const snapshot = createSnapshot([
73
+ "◐ Preparing parallel timer and intent report (Esc to cancel · 198 B)",
74
+ "◎ Running timer (Esc to cancel · 202 B)",
75
+ ].join("\n"));
76
+
77
+ const busyMatcher = Matchers.anyOf(copilotProfile.anchors.busy ?? []);
78
+ const statusMatcher = Matchers.anyOf(copilotProfile.signals?.status ?? []);
79
+ expect(busyMatcher(snapshot)).toBe(true);
80
+ expect(statusMatcher(snapshot)).toBe(true);
81
+ });
82
+
71
83
  it("matches assistant reply starts in non-screen-reader and screen-reader modes", () => {
72
84
  const snapshot = createSnapshot([
73
85
  "● Hello! I'm GitHub Copilot CLI.",
@@ -33,6 +33,20 @@ describe("copilot getSignals current-turn scope", () => {
33
33
  driver.kill();
34
34
  });
35
35
 
36
+ it("captures Running status lines with Esc-to-cancel prefixes", () => {
37
+ const driver = new TuiDriver({ profile: copilotProfile });
38
+ const snapshot = createSnapshot([
39
+ "You said: start timer",
40
+ "◎ Running timer (Esc to cancel · 202 B)",
41
+ "❯ Type @ to mention files, / for commands, or ? for shortcuts",
42
+ ].join("\n"));
43
+
44
+ const signals = driver.getSignals(snapshot);
45
+ expect(signals.statusLine).toBe("◎ Running timer (Esc to cancel · 202 B)");
46
+
47
+ driver.kill();
48
+ });
49
+
36
50
  it("captures reply from the latest turn when using You said markers", () => {
37
51
  const driver = new TuiDriver({ profile: copilotProfile });
38
52
  const snapshot = createSnapshot([
@@ -1,4 +1,7 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import { TuiDriver } from "../src/driver/TuiDriver.js";
3
6
  import { claudeCodeProfile } from "../src/driver/profiles/claudeCode.profile.js";
4
7
  import { codexProfile } from "../src/driver/profiles/codex.profile.js";
@@ -77,6 +80,74 @@ describe("session file reply extraction", () => {
77
80
  driver.kill();
78
81
  });
79
82
 
83
+ it("does not treat copilot turn_end as completed when a follow-up turn starts", () => {
84
+ const driver = new TuiDriver({ profile: codexProfile });
85
+ const lines = [
86
+ JSON.stringify({
87
+ type: "assistant.message",
88
+ data: {
89
+ content: "first reply",
90
+ },
91
+ }),
92
+ JSON.stringify({
93
+ type: "assistant.turn_end",
94
+ data: {
95
+ turnId: "0",
96
+ },
97
+ }),
98
+ JSON.stringify({
99
+ type: "assistant.turn_start",
100
+ data: {
101
+ turnId: "1",
102
+ },
103
+ }),
104
+ ];
105
+
106
+ const detected = (driver as any).hasCopilotTurnEndFromJsonLines(lines);
107
+ expect(detected).toBe(false);
108
+ driver.kill();
109
+ });
110
+
111
+ it("detects final copilot completion when follow-up turn produces assistant text", () => {
112
+ const driver = new TuiDriver({ profile: codexProfile });
113
+ const lines = [
114
+ JSON.stringify({
115
+ type: "assistant.message",
116
+ data: {
117
+ content: "",
118
+ },
119
+ }),
120
+ JSON.stringify({
121
+ type: "assistant.turn_end",
122
+ data: {
123
+ turnId: "0",
124
+ },
125
+ }),
126
+ JSON.stringify({
127
+ type: "assistant.turn_start",
128
+ data: {
129
+ turnId: "1",
130
+ },
131
+ }),
132
+ JSON.stringify({
133
+ type: "assistant.message",
134
+ data: {
135
+ content: "final reply",
136
+ },
137
+ }),
138
+ JSON.stringify({
139
+ type: "assistant.turn_end",
140
+ data: {
141
+ turnId: "1",
142
+ },
143
+ }),
144
+ ];
145
+
146
+ const detected = (driver as any).hasCopilotTurnEndFromJsonLines(lines);
147
+ expect(detected).toBe(true);
148
+ driver.kill();
149
+ });
150
+
80
151
  it("extracts the latest codex assistant message from jsonl lines", () => {
81
152
  const driver = new TuiDriver({ profile: codexProfile });
82
153
  const lines = [
@@ -103,6 +174,197 @@ describe("session file reply extraction", () => {
103
174
  driver.kill();
104
175
  });
105
176
 
177
+ it("reads incremental codex assistant messages from session file and ignores wrapper duplicates", async () => {
178
+ const driver = new TuiDriver({ profile: codexProfile });
179
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-batch-"));
180
+ const sessionFilePath = path.join(tempDir, "session.jsonl");
181
+ fs.writeFileSync(
182
+ sessionFilePath,
183
+ [
184
+ JSON.stringify({
185
+ type: "event_msg",
186
+ payload: {
187
+ type: "agent_message",
188
+ message: "wrapper duplicate",
189
+ },
190
+ }),
191
+ JSON.stringify({
192
+ timestamp: "2026-03-06T08:20:49.689Z",
193
+ type: "response_item",
194
+ payload: {
195
+ type: "message",
196
+ role: "assistant",
197
+ content: [{ type: "output_text", text: "first streamed reply" }],
198
+ },
199
+ }),
200
+ JSON.stringify({
201
+ type: "event_msg",
202
+ payload: {
203
+ type: "task_complete",
204
+ last_agent_message: "first streamed reply",
205
+ },
206
+ }),
207
+ "",
208
+ ].join("\n"),
209
+ "utf8",
210
+ );
211
+
212
+ const batch = await driver.readSessionAssistantMessagesSince(
213
+ {
214
+ backend: "codex",
215
+ sessionId: "session-1",
216
+ sessionFilePath,
217
+ },
218
+ 0,
219
+ );
220
+
221
+ expect(batch.messages).toEqual([
222
+ {
223
+ backend: "codex",
224
+ sessionId: "session-1",
225
+ sessionFilePath,
226
+ text: "first streamed reply",
227
+ timestamp: "2026-03-06T08:20:49.689Z",
228
+ },
229
+ ]);
230
+ expect(batch.nextOffset).toBe(fs.readFileSync(sessionFilePath).length);
231
+ driver.kill();
232
+ });
233
+
234
+ it("keeps incomplete trailing json lines for the next session file poll", async () => {
235
+ const driver = new TuiDriver({ profile: codexProfile });
236
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-partial-"));
237
+ const sessionFilePath = path.join(tempDir, "session.jsonl");
238
+ const firstLine = JSON.stringify({
239
+ type: "response_item",
240
+ payload: {
241
+ type: "message",
242
+ role: "assistant",
243
+ content: [{ type: "output_text", text: "first reply" }],
244
+ },
245
+ });
246
+ const secondLine = JSON.stringify({
247
+ type: "response_item",
248
+ payload: {
249
+ type: "message",
250
+ role: "assistant",
251
+ content: [{ type: "output_text", text: "second reply" }],
252
+ },
253
+ });
254
+ const splitAt = Math.max(1, Math.floor(secondLine.length / 2));
255
+ fs.writeFileSync(
256
+ sessionFilePath,
257
+ `${firstLine}\n${secondLine.slice(0, splitAt)}`,
258
+ "utf8",
259
+ );
260
+
261
+ const firstBatch = await driver.readSessionAssistantMessagesSince(
262
+ {
263
+ backend: "codex",
264
+ sessionId: "session-2",
265
+ sessionFilePath,
266
+ },
267
+ 0,
268
+ );
269
+
270
+ expect(firstBatch.messages.map((message) => message.text)).toEqual(["first reply"]);
271
+
272
+ fs.appendFileSync(sessionFilePath, `${secondLine.slice(splitAt)}\n`, "utf8");
273
+
274
+ const secondBatch = await driver.readSessionAssistantMessagesSince(
275
+ {
276
+ backend: "codex",
277
+ sessionId: "session-2",
278
+ sessionFilePath,
279
+ },
280
+ firstBatch.nextOffset,
281
+ );
282
+
283
+ expect(secondBatch.messages.map((message) => message.text)).toEqual(["second reply"]);
284
+ driver.kill();
285
+ });
286
+
287
+ it("allows codex session idle completion without a fresh task_complete when assistant reply exists", async () => {
288
+ const driver = new TuiDriver({ profile: codexProfile });
289
+ const checkpoint = {
290
+ sessionInfo: {
291
+ backend: "codex",
292
+ sessionId: "session-1",
293
+ sessionFilePath: "/tmp/codex-session-1.jsonl",
294
+ },
295
+ size: 10,
296
+ mtimeMs: 100,
297
+ };
298
+ const stats = [
299
+ { size: 20, mtimeMs: 200 },
300
+ { size: 20, mtimeMs: 200 },
301
+ { size: 20, mtimeMs: 200 },
302
+ ];
303
+ let statIndex = 0;
304
+
305
+ vi.spyOn(driver as any, "assertAliveOrThrow").mockImplementation(() => {});
306
+ vi.spyOn(driver as any, "sleep").mockResolvedValue(undefined);
307
+ vi.spyOn(driver as any, "readSessionFileStat").mockImplementation(async () => {
308
+ const next = stats[Math.min(statIndex, stats.length - 1)];
309
+ statIndex += 1;
310
+ return next;
311
+ });
312
+ vi.spyOn(driver as any, "hasSessionCompletionMarker").mockResolvedValue(false);
313
+ vi.spyOn(driver as any, "hasSessionAssistantReply").mockResolvedValue(true);
314
+
315
+ await expect((driver as any).waitForSessionFileIdle(checkpoint)).resolves.toBeUndefined();
316
+ driver.kill();
317
+ });
318
+
319
+ it("keeps codex completion timeout when neither marker nor assistant reply exists", async () => {
320
+ const driver = new TuiDriver({
321
+ profile: {
322
+ ...codexProfile,
323
+ timeouts: {
324
+ ...codexProfile.timeouts,
325
+ streamEnd: 100,
326
+ },
327
+ },
328
+ });
329
+ const checkpoint = {
330
+ sessionInfo: {
331
+ backend: "codex",
332
+ sessionId: "session-2",
333
+ sessionFilePath: "/tmp/codex-session-2.jsonl",
334
+ },
335
+ size: 10,
336
+ mtimeMs: 100,
337
+ };
338
+ const stats = [
339
+ { size: 20, mtimeMs: 200 },
340
+ { size: 20, mtimeMs: 200 },
341
+ { size: 20, mtimeMs: 200 },
342
+ ];
343
+ let statIndex = 0;
344
+ let now = 0;
345
+
346
+ vi.spyOn(driver as any, "assertAliveOrThrow").mockImplementation(() => {});
347
+ vi.spyOn(driver as any, "sleep").mockResolvedValue(undefined);
348
+ vi.spyOn(driver as any, "readSessionFileStat").mockImplementation(async () => {
349
+ const next = stats[Math.min(statIndex, stats.length - 1)];
350
+ statIndex += 1;
351
+ return next;
352
+ });
353
+ vi.spyOn(driver as any, "hasSessionCompletionMarker").mockResolvedValue(false);
354
+ vi.spyOn(driver as any, "hasSessionAssistantReply").mockResolvedValue(false);
355
+ const dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => {
356
+ now += 30;
357
+ return now;
358
+ });
359
+
360
+ await expect((driver as any).waitForSessionFileIdle(checkpoint)).rejects.toThrow(
361
+ "Stream end timeout: session completion marker not observed",
362
+ );
363
+
364
+ dateNowSpy.mockRestore();
365
+ driver.kill();
366
+ });
367
+
106
368
  it("extracts the latest claude assistant text block", () => {
107
369
  const driver = new TuiDriver({ profile: codexProfile });
108
370
  const lines = [
@@ -157,6 +419,49 @@ describe("session file reply extraction", () => {
157
419
  driver.kill();
158
420
  });
159
421
 
422
+ it("does not fallback to global latest codex session when discovery query misses", async () => {
423
+ const driver = new TuiDriver({ profile: codexProfile });
424
+ const querySpy = vi
425
+ .spyOn(driver as any, "querySqliteRow")
426
+ .mockResolvedValue(null);
427
+ const pathSpy = vi
428
+ .spyOn(driver as any, "pathExists")
429
+ .mockResolvedValue(true);
430
+ const result = await (driver as any).detectCodexSessionInfo();
431
+
432
+ expect(result).toBeNull();
433
+ expect(querySpy).toHaveBeenCalledTimes(1);
434
+ expect(String(querySpy.mock.calls[0]?.[1] || "")).toContain("created_at >=");
435
+
436
+ querySpy.mockRestore();
437
+ pathSpy.mockRestore();
438
+ driver.kill();
439
+ });
440
+
441
+ it("returns codex session when expected session-id query hits", async () => {
442
+ const driver = new TuiDriver({ profile: codexProfile, expectedSessionId: "session-1" });
443
+ const querySpy = vi
444
+ .spyOn(driver as any, "querySqliteRow")
445
+ .mockResolvedValue("session-1|/tmp/codex-session-1.jsonl");
446
+ const pathSpy = vi
447
+ .spyOn(driver as any, "pathExists")
448
+ .mockResolvedValue(true);
449
+
450
+ const result = await (driver as any).detectCodexSessionInfo();
451
+
452
+ expect(result).toEqual({
453
+ backend: "codex",
454
+ sessionId: "session-1",
455
+ sessionFilePath: "/tmp/codex-session-1.jsonl",
456
+ });
457
+ expect(querySpy).toHaveBeenCalledTimes(1);
458
+ expect(String(querySpy.mock.calls[0]?.[1] || "")).toContain("id='session-1'");
459
+
460
+ querySpy.mockRestore();
461
+ pathSpy.mockRestore();
462
+ driver.kill();
463
+ });
464
+
160
465
  it("builds claude and copilot resume args for restart", () => {
161
466
  const claudeDriver = new TuiDriver({
162
467
  profile: {