agentlas 0.5.2 → 0.6.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 (41) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const { compareSemVer, normalizeSemVer, parseSemVer } = require("../engine/semver.cjs");
8
+
9
+ const precedence = [
10
+ "1.0.0-alpha",
11
+ "1.0.0-alpha.1",
12
+ "1.0.0-alpha.beta",
13
+ "1.0.0-beta",
14
+ "1.0.0-beta.2",
15
+ "1.0.0-beta.11",
16
+ "1.0.0-rc.1",
17
+ "1.0.0",
18
+ ];
19
+
20
+ for (let index = 0; index < precedence.length - 1; index += 1) {
21
+ assert.equal(compareSemVer(precedence[index], precedence[index + 1]), -1);
22
+ assert.equal(compareSemVer(precedence[index + 1], precedence[index]), 1);
23
+ }
24
+
25
+ assert.equal(compareSemVer("v2.3.4", "2.3.4"), 0);
26
+ assert.equal(normalizeSemVer("v2.3.4-rc.1+build.7"), "2.3.4-rc.1+build.7");
27
+ assert.equal(compareSemVer("1.0.0+build.1", "1.0.0+build.99"), 0);
28
+ assert.equal(compareSemVer("1.0.0-1", "1.0.0-alpha"), -1);
29
+ assert.equal(compareSemVer("999999999999999999999.0.0", "2.0.0"), 1);
30
+ assert.equal(compareSemVer("1.0.0", "1.0.0-rc.99"), 1);
31
+ assert.equal(parseSemVer("1.0.0-01"), null);
32
+ assert.equal(parseSemVer("01.0.0"), null);
33
+ assert.equal(compareSemVer("not-a-version", "1.0.0"), null);
34
+
35
+ const updater = fs.readFileSync(path.join(__dirname, "../engine/agentlas.cjs"), "utf8");
36
+ assert.match(updater, /compareSemVer\(currentVersion, latestVersion\)/);
37
+ assert.doesNotMatch(updater, /function versionParts/);
38
+
39
+ console.log("semver-precedence: PASS");
package/test/smoke.sh CHANGED
@@ -29,6 +29,26 @@ check "help" node "$BIN" help
29
29
  check "usage" node "$BIN" usage
30
30
  check "mcp" node "$BIN" mcp
31
31
  check "chats" node "$BIN" chats
32
+ check "run-api-regression" node "$SCRIPT_DIR/run-api-regression.cjs"
33
+ check "cloud-runtime-paths" node "$SCRIPT_DIR/cloud-runtime-paths.cjs"
34
+ check "cloud-save-publish" node "$SCRIPT_DIR/cloud-save-publish.cjs"
35
+ check "cloud-asset-restore" node "$SCRIPT_DIR/cloud-asset-restore.cjs"
36
+ check "cloud-owner-restore" node "$SCRIPT_DIR/cloud-owner-restore.cjs"
37
+ check "cloud-cas-client" node "$SCRIPT_DIR/cloud-cas-client.cjs"
38
+ check "runtime-env-protection" node "$SCRIPT_DIR/runtime-env-protection.cjs"
39
+ check "credential-env-regression" node "$SCRIPT_DIR/credential-env-regression.cjs"
40
+ check "tool-workspace-boundary" node "$SCRIPT_DIR/tool-workspace-boundary.cjs"
41
+ check "mcp-config-isolation" node "$SCRIPT_DIR/mcp-config-isolation.cjs"
42
+ check "bootstrap-race" node "$SCRIPT_DIR/bootstrap-race.cjs"
43
+ check "login-loopback-security" node "$SCRIPT_DIR/login-loopback-security.cjs"
44
+ check "timeout-regression" node "$SCRIPT_DIR/timeout-regression.cjs"
45
+ check "terminal-ui-regression" node "$SCRIPT_DIR/terminal-ui-regression.cjs"
46
+ check "route-regression" node "$SCRIPT_DIR/route-regression.cjs"
47
+ check "permission-mapping" node "$SCRIPT_DIR/permission-mapping.cjs"
48
+ check "sqlite-driver-probe" node "$SCRIPT_DIR/sqlite-driver-probe.cjs"
49
+ check "capture-runtime-guard" node "$SCRIPT_DIR/capture-runtime-guard.cjs"
50
+ check "update-safety" node "$SCRIPT_DIR/update-safety.cjs"
51
+ check "semver-precedence" node "$SCRIPT_DIR/semver-precedence.cjs"
32
52
 
33
53
  # Agentlas OS 표면: 무인자 호출은 usage를 내고 exit 1 (프롬프트 오라우팅 방지 확인)
34
54
  guard() {
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("node:assert/strict");
5
+ const { spawnSync } = require("node:child_process");
6
+ const path = require("node:path");
7
+ const { probeSqliteDriver } = require("../bin/agentlas.cjs");
8
+
9
+ const driver = probeSqliteDriver();
10
+ assert.ok(driver === "better-sqlite3" || driver === "node:sqlite", `unexpected SQLite driver: ${driver}`);
11
+
12
+ const launcher = path.join(__dirname, "..", "bin", "agentlas.cjs");
13
+ const result = spawnSync(process.execPath, [launcher, "--where"], {
14
+ encoding: "utf8",
15
+ env: { ...process.env, NODE_NO_WARNINGS: "" },
16
+ });
17
+ assert.equal(result.status, 0, result.stderr || result.stdout);
18
+ assert.doesNotMatch(result.stderr, /ExperimentalWarning|SQLite is an experimental feature/i);
19
+ const where = JSON.parse(result.stdout);
20
+ assert.equal(where.sqliteDriver, driver, "--where must report the driver that can actually open a database");
21
+
22
+ console.log(`sqlite-driver-probe: PASS (${driver})`);
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("node:assert/strict");
5
+ const { EventEmitter } = require("node:events");
6
+ const { PassThrough } = require("node:stream");
7
+ const { Ui, stripAnsi } = require("../engine/agentlas-ui.cjs");
8
+ const { buildComposerFrame, createComposer, visWidth } = require("../engine/agentlas-composer.cjs");
9
+ const { runNativeTurn } = require("../engine/agentlas-native-host.cjs");
10
+ const terminalInput = require("../engine/agentlas-input.cjs");
11
+ const banner = require("../engine/agentlas-banner.cjs");
12
+ const { makeStyleGuard } = require("../engine/agentlas-repl.cjs");
13
+
14
+ function palette() {
15
+ const id = (value) => String(value);
16
+ return { faint: id, emerald: id, text: id, paw: id, amber: id, blue: id, dim: id, inverse: id };
17
+ }
18
+
19
+ function captureStream({ tty = false, columns = 88 } = {}) {
20
+ let value = "";
21
+ return {
22
+ isTTY: tty,
23
+ columns,
24
+ write(chunk) { value += String(chunk); return true; },
25
+ value() { return value; },
26
+ };
27
+ }
28
+
29
+ class FakeInput extends EventEmitter {
30
+ constructor() {
31
+ super();
32
+ this.isTTY = true;
33
+ this.isRaw = false;
34
+ }
35
+ setRawMode(value) { this.isRaw = Boolean(value); }
36
+ resume() {}
37
+ }
38
+
39
+ function plainTerminal(value) {
40
+ // SGR + cursor movement/erase sequences; the remaining text is enough for hierarchy assertions.
41
+ return stripAnsi(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
42
+ }
43
+
44
+ function virtualScreen(value) {
45
+ const input = stripAnsi(value);
46
+ const rows = [[]];
47
+ let row = 0;
48
+ let col = 0;
49
+ const ensure = () => { while (rows.length <= row) rows.push([]); };
50
+ for (let index = 0; index < input.length; ) {
51
+ if (input[index] === "\x1b" && input[index + 1] === "[") {
52
+ const match = /^\x1b\[([0-9]*)([AJ])/.exec(input.slice(index));
53
+ if (match) {
54
+ const amount = Number(match[1] || (match[2] === "A" ? 1 : 0));
55
+ if (match[2] === "A") row = Math.max(0, row - amount);
56
+ if (match[2] === "J") {
57
+ rows[row] = rows[row].slice(0, col);
58
+ rows.length = row + 1;
59
+ }
60
+ index += match[0].length;
61
+ continue;
62
+ }
63
+ }
64
+ const ch = input[index++];
65
+ if (ch === "\r") { col = 0; continue; }
66
+ if (ch === "\n") { row++; col = 0; ensure(); continue; }
67
+ ensure();
68
+ rows[row][col++] = ch;
69
+ }
70
+ return rows.map((line) => line.join("").replace(/\s+$/g, "")).join("\n").replace(/\n+$/g, "");
71
+ }
72
+
73
+ function testComposerHierarchy() {
74
+ const state = { buf: "북마크 동기화 상태 확인", cur: 13, scroll: 0, suggest: [], suggestSel: 0 };
75
+ const frame = buildComposerFrame(state, {
76
+ glyph: "›",
77
+ permission: "write",
78
+ permissionLabel: "읽기 + 쓰기",
79
+ status: "codex · 자동 라우팅 · / 명령 · ↑↓ history",
80
+ }, palette(), 76);
81
+
82
+ assert.equal(frame.lines.length, 4);
83
+ assert.match(frame.lines[0], /^─{76}$/);
84
+ assert.equal(frame.lines[1], "› 북마크 동기화 상태 확인");
85
+ assert.match(frame.lines[2], /^─{76}$/);
86
+ assert.match(frame.lines[3], /^◆ 읽기 \+ 쓰기 · codex/);
87
+ assert.ok(visWidth(frame.lines[3]) <= 76, "status bar must not wrap and break redraw coordinates");
88
+ assert.doesNotMatch(frame.lines.join("\n"), /[╭╮│╰╯]/, "composer must use the reference line hierarchy, not a large card");
89
+ assert.equal(frame.curCol, 25, "Hangul cursor position must use terminal cell width");
90
+
91
+ const warning = buildComposerFrame({ buf: "", cur: 0, scroll: 0, suggest: [], suggestSel: 0 }, {
92
+ lang: "ko",
93
+ permission: "write",
94
+ confirmation: "무제한 권한은 승인과 샌드박스를 우회합니다 — 5초 안에 Shift-Tab을 다시 눌러 확인",
95
+ confirmationTone: "danger",
96
+ }, palette(), 48);
97
+ for (const line of warning.lines) assert.ok(visWidth(line) <= 48, "permission warning must not wrap and corrupt redraw rows");
98
+
99
+ // 연결 LLM 사용량 표시줄 — ctx.usage가 있으면 상태줄 아래 한 줄로 항상 렌더된다.
100
+ const withUsage = buildComposerFrame({ buf: "", cur: 0, scroll: 0, suggest: [], suggestSel: 0 }, {
101
+ lang: "ko",
102
+ permission: "write",
103
+ permissionLabel: "읽기 + 쓰기",
104
+ status: "claude-code · 자동 라우팅",
105
+ usage: "토큰 claude 12.3k→4.5k · codex 0 · gemini 0",
106
+ }, palette(), 76);
107
+ assert.equal(withUsage.lines.length, 5, "usage bar must add exactly one line under the status line");
108
+ assert.match(withUsage.lines[4], /토큰 {2}claude 12\.3k→4\.5k · codex 0 · gemini 0/);
109
+ for (const line of withUsage.lines) assert.ok(visWidth(line) <= 76, "usage bar must never overflow the box width");
110
+ const usageFn = buildComposerFrame({ buf: "", cur: 0, scroll: 0, suggest: [], suggestSel: 0 }, {
111
+ permission: "write",
112
+ usage: () => "tokens claude 1.0k→200",
113
+ }, palette(), 76);
114
+ assert.match(usageFn.lines[usageFn.lines.length - 1], /tokens {2}claude 1\.0k→200/, "usage may be a live getter");
115
+ }
116
+
117
+ function testCompactLocalizedStartup() {
118
+ const stream = captureStream({ columns: 100 });
119
+ const ui = new Ui({ color: false, lang: "ko", stream });
120
+ banner.renderBanner({
121
+ ui,
122
+ version: "0.5.5-test",
123
+ runtimeLabel: "codex",
124
+ subjectLabel: null,
125
+ permission: "write",
126
+ cwd: "/tmp/project",
127
+ });
128
+ const output = stream.value();
129
+ assert.match(output, /AGENTLAS.*v0\.5\.5-test.*Agent OS 터미널/);
130
+ assert.match(output, /codex.*자동 라우팅.*작업 공간 쓰기/);
131
+ assert.match(output, /Shift-Tab 권한/);
132
+ assert.equal(output.trim().split("\n").length, 3, "startup chrome must stay compact");
133
+ assert.doesNotMatch(output, /[╭╮│╰╯]/, "startup must not render the old oversized status card");
134
+ assert.doesNotMatch(output, /████/, "startup must not render the six-row wordmark");
135
+
136
+ const statusStream = captureStream({ columns: 48 });
137
+ const statusUi = new Ui({ color: false, lang: "ko", stream: statusStream });
138
+ banner.renderStatus({
139
+ ui: statusUi,
140
+ runtimeLabel: "Codex 0.144.1",
141
+ subjectLabel: "자동 라우팅 에이전트",
142
+ permission: "write",
143
+ cwd: "/tmp/아주-긴-프로젝트-폴더",
144
+ });
145
+ for (const line of statusStream.value().split("\n")) {
146
+ assert.ok(visWidth(line) <= 48, `localized /status line overflowed: ${line}`);
147
+ }
148
+ }
149
+
150
+ function testCompactToolActivity() {
151
+ const stream = captureStream({ columns: 96 });
152
+ const ui = new Ui({ color: false, lang: "ko", stream });
153
+ ui.tool(
154
+ "Bash",
155
+ "cd /Users/mason/Documents/Agentlas_F && python3 -m pytest tests -q && git status --short && npm run smoke",
156
+ );
157
+ ui.toolResult(["collecting...", "tests/test_sync.py .....", "5 passed in 17.8s"].join("\n"), true);
158
+ const output = stream.value();
159
+ assert.match(output, /● Bash python3 -m pytest tests -q · 3 steps/);
160
+ assert.match(output, /└ ✓ 5 passed in 17\.8s/);
161
+ assert.match(output, /3 output lines/);
162
+ assert.doesNotMatch(output, /\/Users\/mason\/Documents/, "redundant cwd must not dominate the activity line");
163
+ assert.doesNotMatch(output, /tests\/test_sync\.py \.{5}/, "successful raw output should be summarized, not dumped");
164
+
165
+ const pathToken = `ocm_${"example-not-a-real-token-123456789"}`;
166
+ const secretStream = captureStream({ columns: 120 });
167
+ const secretUi = new Ui({ color: false, lang: "ko", stream: secretStream });
168
+ secretUi.tool("Bash", `curl https://opencrab.sh/api/mcp/${pathToken}`);
169
+ assert.doesNotMatch(secretStream.value(), new RegExp(pathToken), "URL-embedded MCP credentials must never reach terminal activity output");
170
+ assert.match(secretStream.value(), /opencrab\.sh\/api\/mcp\/\[redacted\]/);
171
+
172
+ const explicit = captureStream({ columns: 96 });
173
+ const explicitUi = new Ui({ color: false, lang: "ko", stream: explicit });
174
+ explicitUi.tool("$ ls");
175
+ explicitUi.toolResult("one.txt\ntwo.txt", true, { verbose: true });
176
+ assert.match(explicit.value(), /one\.txt\n two\.txt/, "explicit !shell output must remain inspectable");
177
+
178
+ const narrow = captureStream({ columns: 48 });
179
+ const narrowUi = new Ui({ color: false, lang: "ko", stream: narrow });
180
+ narrowUi.tool("Bash", "한국어로 매우 긴 실행 명령을 작성하고 여러 디렉터리를 순회한 뒤 테스트를 수행합니다");
181
+ narrowUi.toolResult("검증 결과: 한국어로 작성된 아주 긴 성공 결과 문장이 터미널 오른쪽 경계를 넘어가면 안 됩니다 SUCCESS", true);
182
+ for (const line of narrow.value().trimEnd().split("\n")) {
183
+ assert.ok(visWidth(line) <= 48, `48-column activity line overflowed (${visWidth(line)} cells): ${line}`);
184
+ }
185
+ }
186
+
187
+ function testPersistentTurnFooter() {
188
+ const stream = captureStream({ tty: true, columns: 84 });
189
+ const ui = new Ui({ color: false, lang: "ko", stream });
190
+ ui.beginTurn({ permission: "write", permissionLabel: "읽기 + 쓰기", status: "codex · 자동 라우팅", usage: () => "토큰 codex 2.0k→800" });
191
+ ui.status("Codex로 생각 중");
192
+ ui.tool("Read", "/Users/mason/Documents/Agentlas_F/README.md");
193
+ ui.toolResult("first\nsecond\nthird", true);
194
+ ui.streamStart(true);
195
+ ui.write("최종 답변\n");
196
+ ui.streamEnd();
197
+ const afterFirstEnd = stream.value();
198
+ const screen = virtualScreen(afterFirstEnd);
199
+ assert.equal((screen.match(/^─+$/gm) || []).length, 2, "footer redraws must leave exactly two separators on screen");
200
+ assert.equal((screen.match(/^›$/gm) || []).length, 1, "footer redraws must leave one composer anchor on screen");
201
+ assert.match(screen, /● Read/);
202
+ assert.match(screen, /최종 답변/);
203
+ ui.streamEnd();
204
+ assert.equal(stream.value(), afterFirstEnd, "a duplicate runtime streamEnd must not append a second footer");
205
+ ui.endTurn();
206
+ const output = plainTerminal(stream.value());
207
+ assert.match(output, /› /, "the input anchor must remain visible during a turn");
208
+ assert.match(output, /Codex로 생각 중/);
209
+ assert.match(output, /ctrl-c로 중단/);
210
+ assert.match(output, /읽기 \+ 쓰기/);
211
+ assert.match(output, /● Read/);
212
+ assert.match(output, /└ ✓ 3 lines read/);
213
+ assert.match(output, /토큰 {2}codex 2\.0k→800/, "usage bar must stay visible in the persistent turn footer");
214
+ }
215
+
216
+ function testRuntimeTaskPanelAndCtrlT() {
217
+ const input = new FakeInput();
218
+ const stream = captureStream({ tty: true, columns: 92 });
219
+ const ui = new Ui({ color: false, lang: "ko", stream, input });
220
+ let interrupted = 0;
221
+ ui.beginTurn({ permission: "write", permissionLabel: "작업 공간 쓰기", status: "codex", onInterrupt: () => { interrupted++; } });
222
+ assert.equal(input.isRaw, true, "active turns need raw mode so macOS Ctrl-T is a key, not SIGINFO");
223
+
224
+ ui.tool("Bash", "npm test");
225
+ assert.equal(ui._turnTasks.length, 0, "ordinary tool activity must never be fabricated into a task plan");
226
+ ui.applyTaskTool("TodoWrite", {
227
+ todos: [
228
+ { content: "CHECKLIST_GHOST_SENTINEL", activeForm: "CHECKLIST_GHOST_SENTINEL", status: "in_progress" },
229
+ { content: "권한 회귀 테스트", status: "completed" },
230
+ { content: "릴리스 확인", status: "pending" },
231
+ ],
232
+ }, "todo-1");
233
+ assert.deepEqual(ui._turnTasks.map((task) => task.status), ["in_progress", "completed", "pending"]);
234
+ assert.match(virtualScreen(stream.value()), /CHECKLIST_GHOST_SENTINEL/);
235
+ const expandedRows = ui._footerDrawnRows;
236
+
237
+ input.emit("keypress", "\x14", { ctrl: true, name: "t" });
238
+ assert.equal(ui._tasksExpanded, false);
239
+ assert.ok(ui._footerDrawnRows < expandedRows, "collapsed task surface must use fewer footer rows");
240
+ assert.doesNotMatch(virtualScreen(stream.value()), /CHECKLIST_GHOST_SENTINEL/, "old task rows must be erased without ghosts");
241
+ input.emit("keypress", "\x14", { ctrl: true, name: "t" });
242
+ assert.equal(ui._tasksExpanded, true);
243
+ input.emit("keypress", "\x03", { ctrl: true, name: "c" });
244
+ assert.equal(interrupted, 1, "raw-mode Ctrl-C must still interrupt the active turn");
245
+
246
+ ui.endTurn();
247
+ assert.equal(input.isRaw, false, "turn cleanup must restore prior terminal raw mode");
248
+ }
249
+
250
+ function testCrossRuntimeTaskNormalization() {
251
+ const stream = captureStream({ tty: false });
252
+ const ui = new Ui({ color: false, lang: "en", stream });
253
+ ui.applyTaskTool("write_todos", { todos: [{ description: "Gemini task", status: "in_progress" }] }, "g-1");
254
+ assert.deepEqual(ui._turnTasks.map((task) => [task.label, task.status]), [["Gemini task", "in_progress"]]);
255
+ ui.replaceTasks({ type: "todo_list", items: [{ text: "Codex task", completed: true }] }, "codex");
256
+ assert.deepEqual(ui._turnTasks.map((task) => [task.label, task.status]), [["Codex task", "completed"]]);
257
+ ui.applyTaskTool("TaskCreate", { subject: "Claude created task" }, "toolu-create-1");
258
+ ui.applyTaskResult("TaskCreate", { task: { id: "1", subject: "Claude created task", status: "pending" } }, "toolu-create-1");
259
+ ui.applyTaskTool("TaskUpdate", { taskId: "1", subject: "Claude created task", status: "in_progress" }, "toolu-update-1");
260
+ assert.deepEqual(ui._turnTasks.at(-1), {
261
+ id: "1",
262
+ label: "Claude created task",
263
+ status: "in_progress",
264
+ source: "taskupdate",
265
+ });
266
+ const beforeGeneric = ui._turnTasks.map((task) => ({ ...task }));
267
+ ui.applyTaskResult("Read", { id: "generic-1", title: "not a task" }, "toolu-read-1");
268
+ assert.deepEqual(ui._turnTasks, beforeGeneric, "generic JSON tool results must never become fabricated tasks");
269
+ ui.applyTaskResult("TaskList", { tasks: [] }, "toolu-list-empty");
270
+ assert.deepEqual(ui._turnTasks, [], "an explicit empty TaskList must clear stale runtime tasks");
271
+ }
272
+
273
+ async function testShiftTabKeyboardAndLocalizedPalette() {
274
+ const input = new FakeInput();
275
+ const stream = captureStream({ tty: true, columns: 88 });
276
+ const ui = new Ui({ color: false, lang: "ko", stream, input });
277
+ const composer = createComposer({ ui, input, stream, loadHistory: () => [], saveHistory: () => {} });
278
+ let cycles = 0;
279
+ let cancels = 0;
280
+ const pending = composer.read({
281
+ lang: "ko",
282
+ permission: "write",
283
+ permissionLabel: "작업 공간 쓰기",
284
+ onCyclePermission: () => {
285
+ cycles++;
286
+ return { permission: "write", permissionLabel: "작업 공간 쓰기", confirmation: "무제한 권한 확인 필요", confirmationTone: "danger" };
287
+ },
288
+ onPermissionCycleCancel: () => { cancels++; return { confirmation: null, confirmationTone: null }; },
289
+ });
290
+ input.emit("keypress", "\x1b[Z", { name: "tab", shift: true });
291
+ assert.equal(cycles, 1, "Shift-Tab must reach the permission cycle handler");
292
+ assert.match(plainTerminal(stream.value()), /! 무제한 권한 확인 필요/, "full escalation warning must be prominent, not a green success");
293
+ input.emit("keypress", "", { name: "left" });
294
+ assert.equal(cancels, 1, "a no-op navigation key must still disarm full escalation");
295
+ assert.doesNotMatch(virtualScreen(stream.value()), /무제한 권한 확인 필요/, "disarmed full warning must be erased immediately");
296
+ input.emit("keypress", "\x1b[Z", { name: "tab", shift: true });
297
+ assert.equal(cycles, 2);
298
+ input.emit("keypress", "x", { name: "x" });
299
+ assert.equal(cancels, 2, "any non-Shift-Tab key must cancel an armed escalation");
300
+ input.emit("keypress", "\r", { name: "return" });
301
+ await pending;
302
+
303
+ const koEntries = terminalInput.slashCommandEntries("ko");
304
+ assert.equal(koEntries.filter((entry) => entry.command === "/install").length, 1, "command palette must dedupe /install");
305
+ assert.match(koEntries.find((entry) => entry.command === "/help").description, /명령/);
306
+ assert.doesNotMatch(koEntries.find((entry) => entry.command === "/help").description, /단축키 보기$/);
307
+ const bareSlashRows = terminalInput.slashCommandSuggestions("/", 12, "ko");
308
+ assert.ok(bareSlashRows.length > 0, "bare / must open the command palette instead of being treated as filesystem root");
309
+ const paletteText = terminalInput.renderSlashPalette(bareSlashRows, 0, { lang: "ko", columns: 48 });
310
+ assert.ok(paletteText.length > 0, "localized command palette must render content");
311
+ for (const line of paletteText.split("\n")) assert.ok(visWidth(line) <= 48, `localized palette line overflowed: ${line}`);
312
+ const careerRows = terminalInput.slashCommandSuggestions("/career", 12, "ko");
313
+ const careerIndex = careerRows.findIndex((entry) => entry.examples?.length);
314
+ assert.ok(careerIndex >= 0, "career palette fixture must include examples");
315
+ const careerPalette = terminalInput.renderSlashPalette(careerRows, careerIndex, { lang: "ko", columns: 48 });
316
+ for (const line of careerPalette.split("\n")) assert.ok(visWidth(line) <= 48, `localized examples line overflowed: ${line}`);
317
+ }
318
+
319
+ class FakeChild extends EventEmitter {
320
+ constructor() {
321
+ super();
322
+ this.stdout = new PassThrough();
323
+ this.stderr = new PassThrough();
324
+ }
325
+ kill() { return true; }
326
+ finish() {
327
+ this.stdout.end();
328
+ this.stderr.end();
329
+ setImmediate(() => this.emit("close", 0));
330
+ }
331
+ }
332
+
333
+ async function testCodexEventRendering() {
334
+ const child = new FakeChild();
335
+ const stream = captureStream({ columns: 100 });
336
+ const ui = new Ui({ color: false, lang: "ko", stream });
337
+ const guardedUi = makeStyleGuard(ui);
338
+ const run = runNativeTurn({
339
+ kind: "codex",
340
+ bin: "fake-codex",
341
+ prompt: "검증해",
342
+ systemPrompt: "system",
343
+ cwd: process.cwd(),
344
+ permission: "read",
345
+ session: {},
346
+ env: {},
347
+ prepareRuntimeEnv: false,
348
+ ui: guardedUi,
349
+ spawn: () => child,
350
+ timeoutConfig: { idleMs: 1_000, totalMs: 2_000, killGraceMs: 20 },
351
+ });
352
+
353
+ setImmediate(() => {
354
+ const events = [
355
+ { type: "thread.started", thread_id: "thread-test" },
356
+ { type: "turn.started" },
357
+ { type: "item.updated", item: { id: "todo-1", type: "todo_list", items: [{ text: "Codex emitted task", completed: false }] } },
358
+ { type: "item.started", item: { id: "cmd-1", type: "command_execution", command: "cd /tmp/project && npm test && git status --short" } },
359
+ { type: "item.completed", item: { id: "cmd-1", type: "command_execution", command: "cd /tmp/project && npm test && git status --short", aggregated_output: "suite a\nsuite b\n12 passed in 2.3s", exit_code: 0 } },
360
+ { type: "item.completed", item: { id: "msg-1", type: "agent_message", text: "검증 완료" } },
361
+ { type: "turn.completed", usage: { input_tokens: 120, output_tokens: 30 } },
362
+ ];
363
+ for (const event of events) child.stdout.write(JSON.stringify(event) + "\n");
364
+ child.finish();
365
+ });
366
+
367
+ const result = await run;
368
+ const output = stream.value();
369
+ assert.equal(result.text, "검증 완료");
370
+ assert.match(output, /● Bash npm test · 2 steps/);
371
+ assert.match(output, /└ ✓ 12 passed in 2\.3s/);
372
+ assert.doesNotMatch(output, /suite a\nsuite b/, "Codex event rendering must use the compact activity summary");
373
+ assert.deepEqual(ui._turnTasks.map((task) => [task.label, task.status]), [["Codex emitted task", "pending"]]);
374
+ }
375
+
376
+ async function testClaudeAndGeminiTaskEvents() {
377
+ const claudeChild = new FakeChild();
378
+ const claudeUi = new Ui({ color: false, lang: "ko", stream: captureStream() });
379
+ const guardedClaudeUi = makeStyleGuard(claudeUi);
380
+ const claudeRun = runNativeTurn({
381
+ kind: "claude-code",
382
+ bin: "fake-claude",
383
+ prompt: "검증",
384
+ systemPrompt: "system",
385
+ cwd: process.cwd(),
386
+ permission: "read",
387
+ session: {},
388
+ env: {},
389
+ ui: guardedClaudeUi,
390
+ spawn: () => claudeChild,
391
+ timeoutConfig: { idleMs: 1_000, totalMs: 2_000, killGraceMs: 20 },
392
+ });
393
+ setImmediate(() => {
394
+ const todos = JSON.stringify({ todos: [{ content: "Claude emitted task", activeForm: "Spinning Claude phrase", status: "in_progress" }] });
395
+ const create = JSON.stringify({ subject: "Claude created task", description: "real task", activeForm: "Creating task" });
396
+ const update = JSON.stringify({ taskId: "1", subject: "Claude created task", status: "in_progress" });
397
+ const events = [
398
+ { type: "system", subtype: "init", session_id: "claude-test" },
399
+ { type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "tool-1", name: "TodoWrite" } } },
400
+ { type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: todos } } },
401
+ { type: "stream_event", event: { type: "content_block_stop", index: 0 } },
402
+ { type: "stream_event", event: { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "tool-create", name: "TaskCreate" } } },
403
+ { type: "stream_event", event: { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: create } } },
404
+ { type: "stream_event", event: { type: "content_block_stop", index: 1 } },
405
+ {
406
+ type: "user",
407
+ message: { content: [{ type: "tool_result", tool_use_id: "tool-create", content: "Task #1 created successfully: Claude created task" }] },
408
+ toolUseResult: { task: { id: "1", subject: "Claude created task", status: "pending" } },
409
+ },
410
+ { type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } },
411
+ { type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "continuing" } } },
412
+ { type: "stream_event", event: { type: "content_block_stop", index: 0 } },
413
+ { type: "stream_event", event: { type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tool-update", name: "TaskUpdate" } } },
414
+ { type: "stream_event", event: { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: update } } },
415
+ { type: "stream_event", event: { type: "content_block_stop", index: 2 } },
416
+ { type: "result", result: "done", duration_ms: 1, usage: { input_tokens: 1, output_tokens: 1 } },
417
+ ];
418
+ for (const event of events) claudeChild.stdout.write(JSON.stringify(event) + "\n");
419
+ claudeChild.finish();
420
+ });
421
+ await claudeRun;
422
+ assert.deepEqual(claudeUi._turnTasks.map((task) => [task.id, task.label, task.status]), [
423
+ ["todowrite:0", "Claude emitted task", "in_progress"],
424
+ ["1", "Claude created task", "in_progress"],
425
+ ], "guarded live path must correlate TaskCreate tool result id with later TaskUpdate without duplicates");
426
+
427
+ const geminiChild = new FakeChild();
428
+ const geminiUi = new Ui({ color: false, lang: "en", stream: captureStream() });
429
+ const guardedGeminiUi = makeStyleGuard(geminiUi);
430
+ const geminiRun = runNativeTurn({
431
+ kind: "gemini",
432
+ bin: "fake-gemini",
433
+ prompt: "verify",
434
+ systemPrompt: "system",
435
+ cwd: process.cwd(),
436
+ permission: "read",
437
+ session: {},
438
+ env: {},
439
+ ui: guardedGeminiUi,
440
+ spawn: () => geminiChild,
441
+ timeoutConfig: { idleMs: 1_000, totalMs: 2_000, killGraceMs: 20 },
442
+ });
443
+ setImmediate(() => {
444
+ const events = [
445
+ { type: "init", session_id: "gemini-test" },
446
+ { type: "tool_use", tool_id: "g-1", tool_name: "write_todos", parameters: { todos: [{ description: "Gemini emitted task", status: "pending" }] } },
447
+ { type: "result", status: "success", stats: { input_tokens: 1, output_tokens: 1, duration_ms: 1 } },
448
+ ];
449
+ for (const event of events) geminiChild.stdout.write(JSON.stringify(event) + "\n");
450
+ geminiChild.finish();
451
+ });
452
+ await geminiRun;
453
+ assert.deepEqual(geminiUi._turnTasks.map((task) => [task.label, task.status]), [["Gemini emitted task", "pending"]]);
454
+ }
455
+
456
+ async function main() {
457
+ testComposerHierarchy();
458
+ testCompactLocalizedStartup();
459
+ testCompactToolActivity();
460
+ testPersistentTurnFooter();
461
+ testRuntimeTaskPanelAndCtrlT();
462
+ testCrossRuntimeTaskNormalization();
463
+ await testShiftTabKeyboardAndLocalizedPalette();
464
+ await testCodexEventRendering();
465
+ await testClaudeAndGeminiTaskEvents();
466
+ console.log("terminal-ui-regression: PASS");
467
+ }
468
+
469
+ main().catch((error) => {
470
+ console.error(error);
471
+ process.exitCode = 1;
472
+ });