@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/execution/__tests__/subagent-service-parent-guard.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [v4 A-5 / P7] getRecordForAction 直接父校验单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 背景:递归 subagent 场景下,孙级 record(parentRecordId = 某子进程的 self recordId)
|
|
6
|
+
// 的子进程句柄只存在于其直接父进程内存。SP-8 后全树 record 的 rootSessionId 贯穿真 ROOT,
|
|
7
|
+
// 旧实现 getRecordForAction 仅校验 rootSessionId → 主进程可凭 rootSessionId 通过 message 孙级,
|
|
8
|
+
// 但孙级 B 的子进程句柄只在 A 进程里 → 主进程走冷路径重新 spawn → 双写同一 session 文件(P7)。
|
|
9
|
+
//
|
|
10
|
+
// 修复:rootSessionId 校验之后增加直接父校验——record.parentRecordId 必须等于本进程
|
|
11
|
+
// execCtxBaseline.recordId(主进程 baseline=null → 只能操作根层 record)。
|
|
12
|
+
//
|
|
13
|
+
// 本测试 mock execCtxBaseline 三种身份(undefined=主进程 / sa-A=直接父 / sa-B=兄弟进程),
|
|
14
|
+
// 断言对 record B(parentRecordId=sa-A)及顶层 record 的操作权限矩阵。
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
// mock logger(import 链需要 getLogger 存在)
|
|
23
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
24
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({ getLogger: () => loggerMock }));
|
|
27
|
+
|
|
28
|
+
// mock session-runner(import 链需要 runSpawn/killAllSpawnedChildren/getChildByRecord 存在)
|
|
29
|
+
vi.mock("../session-runner.ts", () => ({
|
|
30
|
+
runSpawn: vi.fn(),
|
|
31
|
+
killAllSpawnedChildren: vi.fn(),
|
|
32
|
+
getChildByRecord: vi.fn(() => undefined),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { createRecord } from "../execution-record.ts";
|
|
36
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
37
|
+
import { RecordStore } from "../record-store.ts";
|
|
38
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
39
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
40
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
41
|
+
|
|
42
|
+
type Baseline = { recordId: string | undefined; depth: number } | null;
|
|
43
|
+
const MAIN_PROCESS: Baseline = null;
|
|
44
|
+
const PARENT_A: Baseline = { recordId: "sa-A", depth: 1 };
|
|
45
|
+
const SIBLING_B: Baseline = { recordId: "sa-B", depth: 1 };
|
|
46
|
+
|
|
47
|
+
function makeTmpAgentDir(): string {
|
|
48
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "parent-guard-"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makePi(): PiLike {
|
|
52
|
+
return {
|
|
53
|
+
appendEntry: vi.fn(),
|
|
54
|
+
events: { emit: vi.fn() },
|
|
55
|
+
sendMessage: vi.fn(),
|
|
56
|
+
} as unknown as PiLike;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 暴露私有字段供测试注入身份。 */
|
|
60
|
+
interface ServiceInternals {
|
|
61
|
+
store: RecordStore;
|
|
62
|
+
sessionRootId: string | null;
|
|
63
|
+
execCtxBaseline: { recordId: string | undefined; depth: number } | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 构造一个 chatMode idle record(message/close action 的典型目标态)。 */
|
|
67
|
+
function makeRecord(
|
|
68
|
+
id: string,
|
|
69
|
+
sessionRootId: string,
|
|
70
|
+
overrides: Partial<ExecutionRecord> = {},
|
|
71
|
+
): ExecutionRecord {
|
|
72
|
+
return createRecord(id, {
|
|
73
|
+
agent: "general-purpose",
|
|
74
|
+
model: "test/model",
|
|
75
|
+
mode: "background",
|
|
76
|
+
task: "test",
|
|
77
|
+
slug: "test",
|
|
78
|
+
startedAt: 1000,
|
|
79
|
+
rootSessionId: sessionRootId,
|
|
80
|
+
chatMode: true,
|
|
81
|
+
controller: new AbortController(),
|
|
82
|
+
...overrides,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("[v4 A-5 / P7] getRecordForAction 直接父校验", () => {
|
|
87
|
+
let agentDir: string;
|
|
88
|
+
let service: SubagentService;
|
|
89
|
+
let store: RecordStore;
|
|
90
|
+
let sessionRootId: string;
|
|
91
|
+
let internals: ServiceInternals;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
agentDir = makeTmpAgentDir();
|
|
95
|
+
const modelService = new ModelConfigService({ agentDir });
|
|
96
|
+
service = new SubagentService({ cwd: agentDir, modelService });
|
|
97
|
+
// initSession 建立 sessionRootId=sessionId(本 session 树的根)。execCtxBaseline 的
|
|
98
|
+
// 真实值由 initSession 从 env 读取(见 recursive-visibility-baseline.test.ts),但本测试
|
|
99
|
+
// 聚焦守卫逻辑,故每例直接注入身份(mock),不依赖进程 env(测试进程可能继承 subagent env)。
|
|
100
|
+
service.initSession({ pi: makePi(), sessionId: "root-session" });
|
|
101
|
+
internals = service as unknown as ServiceInternals;
|
|
102
|
+
store = internals.store;
|
|
103
|
+
sessionRootId = internals.sessionRootId!;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
service.dispose();
|
|
108
|
+
fs.rmSync(agentDir, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------- 身份 sa-A:直接父,放行孙级 B ----------
|
|
112
|
+
|
|
113
|
+
it("身份=sa-A(直接父)→ 放行操作孙级 record B(parentRecordId=sa-A)", () => {
|
|
114
|
+
const recordB = makeRecord("sa-B", sessionRootId, { parentRecordId: "sa-A", depth: 2, status: "idle" });
|
|
115
|
+
store.register(recordB);
|
|
116
|
+
internals.execCtxBaseline = PARENT_A;
|
|
117
|
+
|
|
118
|
+
expect(service.getRecordForAction("sa-B")).toBe(recordB);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------- 身份 undefined(主进程):拒绝孙级,放行根层 ----------
|
|
122
|
+
|
|
123
|
+
it("身份=undefined(主进程)拒绝孙级 record B,错误含 'direct parent' + 'parent=sa-A'", () => {
|
|
124
|
+
const recordB = makeRecord("sa-B", sessionRootId, { parentRecordId: "sa-A", depth: 2, status: "idle" });
|
|
125
|
+
store.register(recordB);
|
|
126
|
+
internals.execCtxBaseline = MAIN_PROCESS;
|
|
127
|
+
|
|
128
|
+
expect(() => service.getRecordForAction("sa-B")).toThrow(/direct parent/);
|
|
129
|
+
expect(() => service.getRecordForAction("sa-B")).toThrow(/see \/subagents list, parent=sa-A/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("身份=undefined(主进程)放行顶层 record(parentRecordId=undefined 视为根层)", () => {
|
|
133
|
+
const topRecord = makeRecord("sa-top", sessionRootId, { parentRecordId: undefined, depth: 0, status: "idle" });
|
|
134
|
+
store.register(topRecord);
|
|
135
|
+
internals.execCtxBaseline = MAIN_PROCESS;
|
|
136
|
+
|
|
137
|
+
expect(service.getRecordForAction("sa-top")).toBe(topRecord);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------- 身份 sa-B(兄弟进程):拒绝孙级 ----------
|
|
141
|
+
|
|
142
|
+
it("身份=sa-B(兄弟进程)拒绝孙级 record B(parentRecordId=sa-A ≠ baseline sa-B)", () => {
|
|
143
|
+
const recordB = makeRecord("sa-B", sessionRootId, { parentRecordId: "sa-A", depth: 2, status: "idle" });
|
|
144
|
+
store.register(recordB);
|
|
145
|
+
internals.execCtxBaseline = SIBLING_B;
|
|
146
|
+
|
|
147
|
+
expect(() => service.getRecordForAction("sa-B")).toThrow(/direct parent/);
|
|
148
|
+
expect(() => service.getRecordForAction("sa-B")).toThrow(/see \/subagents list, parent=sa-A/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------- 身份缺省语义 + 更深递归 ----------
|
|
152
|
+
|
|
153
|
+
it("子进程(baseline=sa-A)拒绝顶层 record(身份缺省视为根层,仅主进程可操作)", () => {
|
|
154
|
+
const topRecord = makeRecord("sa-top", sessionRootId, { parentRecordId: undefined, depth: 0, status: "idle" });
|
|
155
|
+
store.register(topRecord);
|
|
156
|
+
internals.execCtxBaseline = PARENT_A;
|
|
157
|
+
|
|
158
|
+
expect(() => service.getRecordForAction("sa-top")).toThrow(/direct parent/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("P7 场景:主进程拒绝曾孙 record C(parentRecordId=sa-B),错误含 baseline=root + parent=sa-B", () => {
|
|
162
|
+
const recordC = makeRecord("sa-C", sessionRootId, { parentRecordId: "sa-B", depth: 3, status: "idle" });
|
|
163
|
+
store.register(recordC);
|
|
164
|
+
internals.execCtxBaseline = MAIN_PROCESS;
|
|
165
|
+
|
|
166
|
+
const fn = () => service.getRecordForAction("sa-C");
|
|
167
|
+
expect(fn).toThrow(/direct parent/);
|
|
168
|
+
expect(fn).toThrow(/see \/subagents list, parent=sa-B/);
|
|
169
|
+
expect(fn).toThrow(/baseline=\(root\)/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rootSessionId 不匹配仍优先拒绝(直接父校验在 rootSessionId 校验之后,不绕过归属守卫)", () => {
|
|
173
|
+
// 跨 session 树的 record:rootSessionId 不匹配 → 走首个 throw(not found or not owned)
|
|
174
|
+
const foreignRecord = makeRecord("sa-foreign", "other-session", { parentRecordId: "sa-A", depth: 2, status: "idle" });
|
|
175
|
+
store.register(foreignRecord);
|
|
176
|
+
internals.execCtxBaseline = PARENT_A;
|
|
177
|
+
|
|
178
|
+
expect(() => service.getRecordForAction("sa-foreign")).toThrow(/not found or not owned/);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -25,6 +25,7 @@ import type { UiRequest, UiRequestHandler } from "../dialog-queue.ts";
|
|
|
25
25
|
import type { PiLike } from "../subagent-service.ts";
|
|
26
26
|
import { getSubagentService, setSubagentService,SubagentService } from "../subagent-service.ts";
|
|
27
27
|
import type { ExecutionRecord } from "../types.ts";
|
|
28
|
+
import type { WorktreeManager } from "../worktree-manager.ts";
|
|
28
29
|
|
|
29
30
|
// ── 工具:建临时 agentDir + 真实 ModelConfigService ──
|
|
30
31
|
|
|
@@ -468,11 +469,49 @@ describe("SubagentService", () => {
|
|
|
468
469
|
expect((err as Error).message).not.toMatch(/requires fork/);
|
|
469
470
|
}
|
|
470
471
|
});
|
|
471
|
-
});
|
|
472
472
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
473
|
+
// ============================================================
|
|
474
|
+
// create-await 竞态守卫(Phase 2):create 的 await 窗口内 dispose/cancel
|
|
475
|
+
// 可把 record CAS 成 closed——守卫须主动 cleanup + early-failed 返回,不 kickOff。
|
|
476
|
+
// 实现约束固化:「赋值 record.worktreeHandle → 终态检查 → kickOffBackground」
|
|
477
|
+
// 必须同一同步段(中间禁止 await),本用例即该不变量的回归锚点。
|
|
478
|
+
// ============================================================
|
|
479
|
+
it("守卫:create await 窗口内 dispose 抢先 → cleanup 被调 + early-failed 返回(不 kickOff)", async () => {
|
|
480
|
+
const service = makeReadyService();
|
|
481
|
+
const wtm = Reflect.get(service, "worktreeManager") as WorktreeManager;
|
|
482
|
+
const handle = Object.freeze({
|
|
483
|
+
path: "/tmp/wt-guard",
|
|
484
|
+
branch: "pi-sub-guard",
|
|
485
|
+
baseCommit: "abc123",
|
|
486
|
+
mainCwd: "/repo",
|
|
487
|
+
}) as Parameters<WorktreeManager["cleanup"]>[0];
|
|
488
|
+
let resolveCreate!: (h: unknown) => void;
|
|
489
|
+
vi.spyOn(wtm, "create").mockImplementation(
|
|
490
|
+
() => new Promise((r) => { resolveCreate = r; }) as ReturnType<WorktreeManager["create"]>,
|
|
491
|
+
);
|
|
492
|
+
const cleanupSpy = vi.spyOn(wtm, "cleanup").mockResolvedValue(undefined);
|
|
493
|
+
|
|
494
|
+
const execP = service.execute({
|
|
495
|
+
task: "guard test",
|
|
496
|
+
worktree: true,
|
|
497
|
+
fork: false,
|
|
498
|
+
ctxModel: { id: "ctx-model", name: "Ctx", provider: "p", reasoning: false },
|
|
499
|
+
});
|
|
500
|
+
// 微任务推进:record 已建 + execute 挂在 pending create 上
|
|
501
|
+
await new Promise((r) => { setTimeout(r, 0); });
|
|
502
|
+
// dispose 抢先(无需 record id):CAS running → closed,此时 worktreeHandle 仍
|
|
503
|
+
// undefined(dispose 的 fire-and-forget cleanup 跳过)——守卫是唯一的清理点
|
|
504
|
+
service.disposeAllRecords("parent-shutdown");
|
|
505
|
+
resolveCreate(handle);
|
|
506
|
+
|
|
507
|
+
const ret = await execP;
|
|
508
|
+
// 守卫生效:handle 被主动清理(不等 60s reaper)
|
|
509
|
+
expect(cleanupSpy).toHaveBeenCalledWith(handle);
|
|
510
|
+
// 返回 early-failed 形态(details.status 已 closed),而非 kickOff 的 running 形态
|
|
511
|
+
expect(ret.mode).toBe("background");
|
|
512
|
+
expect(ret.details).toMatchObject({ status: "closed" });
|
|
513
|
+
});
|
|
514
|
+
});
|
|
476
515
|
//
|
|
477
516
|
// [背景] PR #82 在 subagent-service.ts 新增 emitPendingRegister/Unregister 调用,
|
|
478
517
|
// 5 个 emit 点:register(execute L309)、unregister(failed)(finalizeFailed 经
|
|
@@ -572,11 +611,11 @@ describe("SubagentService", () => {
|
|
|
572
611
|
// unregister(failed) 只记 registry 状态(通知由 BgNotifier 发,不在这条事件里)
|
|
573
612
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
574
613
|
"pending:unregister",
|
|
575
|
-
expect.objectContaining({ reason: "
|
|
614
|
+
expect.objectContaining({ reason: "closed" }),
|
|
576
615
|
);
|
|
577
616
|
});
|
|
578
617
|
|
|
579
|
-
it("cancel(background) → unregister(reason=
|
|
618
|
+
it("cancel(background) → unregister(reason=closed) 被 emit,register 未被 emit(v4 B-1 cancelled 折入 closed)", () => {
|
|
580
619
|
const { service, pi } = makeReadyServiceWithPi();
|
|
581
620
|
const record = injectRunningBackground(service, "bg-cancel-1");
|
|
582
621
|
expect(record.status).toBe("running");
|
|
@@ -587,7 +626,7 @@ describe("SubagentService", () => {
|
|
|
587
626
|
expect(ok).toBe(true);
|
|
588
627
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
589
628
|
"pending:unregister",
|
|
590
|
-
expect.objectContaining({ id: "bg-cancel-1", reason: "
|
|
629
|
+
expect.objectContaining({ id: "bg-cancel-1", reason: "closed" }),
|
|
591
630
|
);
|
|
592
631
|
// record 手动注入(未走 execute)→ register 不应被 emit
|
|
593
632
|
expect(pi.events.emit).not.toHaveBeenCalledWith("pending:register", expect.anything());
|
|
@@ -613,15 +652,15 @@ describe("SubagentService", () => {
|
|
|
613
652
|
// 每个 running record 都 emit 了 pending:unregister(reason=failed)
|
|
614
653
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
615
654
|
"pending:unregister",
|
|
616
|
-
expect.objectContaining({ id: "bg-dispose-1", reason: "
|
|
655
|
+
expect.objectContaining({ id: "bg-dispose-1", reason: "closed" }),
|
|
617
656
|
);
|
|
618
657
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
619
658
|
"pending:unregister",
|
|
620
|
-
expect.objectContaining({ id: "bg-dispose-2", reason: "
|
|
659
|
+
expect.objectContaining({ id: "bg-dispose-2", reason: "closed" }),
|
|
621
660
|
);
|
|
622
661
|
expect(pi.events.emit).toHaveBeenCalledWith(
|
|
623
662
|
"pending:unregister",
|
|
624
|
-
expect.objectContaining({ id: "bg-dispose-3", reason: "
|
|
663
|
+
expect.objectContaining({ id: "bg-dispose-3", reason: "closed" }),
|
|
625
664
|
);
|
|
626
665
|
});
|
|
627
666
|
|
|
@@ -746,5 +785,4 @@ describe("ModelConfigService ctxModel 缓存", () => {
|
|
|
746
785
|
// - run() 事件累积(turn_end / message_end usage / tool_start+end / error stopReason)
|
|
747
786
|
// - sync signal abort → cancelled
|
|
748
787
|
// - schema enforcement steer(漏调 structured-output)
|
|
749
|
-
// - onUpdate 回流(TRIGGERING_EVENT_TYPES)
|
|
750
788
|
// 同时覆盖 session-runner.run() —— event-bridge 合并进 run() 后的事件处理回归。
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
//
|
|
16
16
|
// mock 策略(与 run-spawn-integration.test.ts / run-spawn-edges.test.ts 一致):
|
|
17
17
|
// - node:child_process.spawn → 返回 FakeChild(EventEmitter + PassThrough)。
|
|
18
|
-
// - node:child_process.
|
|
18
|
+
// - node:child_process.execFile → err-first callback 默认兜底(buildEnvBlock git branch
|
|
19
|
+
// 失败 → catch → branch="")。
|
|
19
20
|
// - node:fs 同步方法 → mock(避免触碰真实文件系统),promises 保留真实实现。
|
|
20
21
|
// - temp-prompt → mock(返回固定路径,消除 fake-timers flaky)。
|
|
21
22
|
// - alive-store.writeAliveMarker → mock。
|
|
@@ -35,6 +36,7 @@ vi.mock("node:child_process", async () => {
|
|
|
35
36
|
class FakeChild extends EventEmitter {
|
|
36
37
|
pid = 12345;
|
|
37
38
|
stdout = new PassThrough();
|
|
39
|
+
stdin = new PassThrough(); // runSpawn 注册 stdin EPIPE handler(session-runner),FakeChild 必须提供
|
|
38
40
|
stderr = new PassThrough();
|
|
39
41
|
killed = false;
|
|
40
42
|
killSignal: string | undefined;
|
|
@@ -47,7 +49,15 @@ vi.mock("node:child_process", async () => {
|
|
|
47
49
|
|
|
48
50
|
return {
|
|
49
51
|
spawn: vi.fn(() => new FakeChild()),
|
|
50
|
-
|
|
52
|
+
// buildEnvBlock 用 execFile 异步取 git branch:默认 err-first 兜底(catch → branch="")
|
|
53
|
+
execFile: vi.fn(
|
|
54
|
+
(
|
|
55
|
+
_cmd: string,
|
|
56
|
+
_args: readonly string[],
|
|
57
|
+
_opts: unknown,
|
|
58
|
+
cb: (err: Error | null, stdout?: string, stderr?: string) => void,
|
|
59
|
+
) => cb(new Error("execFile not configured in this test")),
|
|
60
|
+
),
|
|
51
61
|
};
|
|
52
62
|
});
|
|
53
63
|
|
|
@@ -83,14 +93,13 @@ vi.mock("../temp-prompt.ts", () => ({
|
|
|
83
93
|
cleanupTempPrompt: vi.fn(async () => {}),
|
|
84
94
|
}));
|
|
85
95
|
|
|
86
|
-
import {
|
|
96
|
+
import { spawn } from "node:child_process";
|
|
87
97
|
import * as fs from "node:fs";
|
|
88
98
|
|
|
89
99
|
import { createRecord } from "../execution-record.ts";
|
|
90
100
|
import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
|
|
91
101
|
|
|
92
102
|
const mockSpawn = vi.mocked(spawn);
|
|
93
|
-
const mockExec = vi.mocked(execFileSync);
|
|
94
103
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
95
104
|
|
|
96
105
|
/**
|
|
@@ -186,33 +195,39 @@ function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerCo
|
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
/**
|
|
189
|
-
* fake timers 下推进时间直到 spawn
|
|
198
|
+
* fake timers 下推进时间直到 spawn 被调用(返回 child 控制器,与共享 helper 的 void 返回
|
|
199
|
+
* 不同,故就地维护)。
|
|
200
|
+
*
|
|
201
|
+
* [快照语义] 同 helpers/spawn-mock.ts 的 waitForSpawn:记调用时 baseline,等待其后的
|
|
202
|
+
* **新** spawn——支持同一测试/文件内多次 runSpawn(旧 `length === 0` 只对首次有效)。
|
|
190
203
|
*
|
|
191
204
|
* runSpawn 在 mkdirSync + writePromptToTempFile(mock 的 async I/O)之后才调 spawn。
|
|
192
205
|
* 每次推进 10ms 让轮询 setTimeout 触发,advanceTimersByTimeAsync 同时 flush 已 resolve
|
|
193
206
|
* 的 I/O promise,使 runSpawn 继续走到 spawn。
|
|
194
207
|
*/
|
|
195
208
|
async function waitForSpawnFake(timeoutSteps = 200): Promise<FakeChild> {
|
|
209
|
+
const baseline = mockSpawn.mock.results.length;
|
|
196
210
|
for (let i = 0; i < timeoutSteps; i++) {
|
|
197
|
-
if (mockSpawn.mock.results.length >
|
|
211
|
+
if (mockSpawn.mock.results.length > baseline) break;
|
|
198
212
|
await vi.advanceTimersByTimeAsync(10);
|
|
199
213
|
}
|
|
200
|
-
if (mockSpawn.mock.results.length
|
|
214
|
+
if (mockSpawn.mock.results.length <= baseline) {
|
|
201
215
|
throw new Error("spawn was not called (fake timers did not progress to spawn)");
|
|
202
216
|
}
|
|
203
217
|
return lastSpawnedChild();
|
|
204
218
|
}
|
|
205
219
|
|
|
206
220
|
/**
|
|
207
|
-
* 真实 timers 下轮询直到 spawn
|
|
208
|
-
*
|
|
221
|
+
* 真实 timers 下轮询直到 spawn 被调用(返回 child 控制器;用于 signal abort 测试——
|
|
222
|
+
* 不需要推进 watchdog,用 queueMicrotask 触发 abort,真实 timers 下 mock I/O 正常 resolve)。
|
|
209
223
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
224
|
+
* [快照语义] 同 helpers/spawn-mock.ts 的 waitForSpawn:记 baseline 等**新** spawn,
|
|
225
|
+
* 支持同文件多次 runSpawn。
|
|
212
226
|
*/
|
|
213
227
|
async function waitForSpawnReal(timeoutMs = 1000): Promise<FakeChild> {
|
|
214
228
|
const start = Date.now();
|
|
215
|
-
|
|
229
|
+
const baseline = mockSpawn.mock.results.length;
|
|
230
|
+
while (mockSpawn.mock.results.length <= baseline) {
|
|
216
231
|
if (Date.now() - start > timeoutMs) {
|
|
217
232
|
throw new Error(`spawn was not called within ${timeoutMs}ms`);
|
|
218
233
|
}
|
|
@@ -228,7 +243,6 @@ async function waitForSpawnReal(timeoutMs = 1000): Promise<FakeChild> {
|
|
|
228
243
|
describe("timeoutMs / signal abort → child.kill 端到端路径", () => {
|
|
229
244
|
beforeEach(() => {
|
|
230
245
|
vi.clearAllMocks();
|
|
231
|
-
mockExec.mockReturnValue("");
|
|
232
246
|
mockExistsSync.mockReturnValue(false);
|
|
233
247
|
});
|
|
234
248
|
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
|
|
19
19
|
function makeDetails(over: Partial<SubagentToolDetails> = {}): SubagentToolDetails {
|
|
20
20
|
return {
|
|
21
|
-
status: "
|
|
21
|
+
status: "closed",
|
|
22
22
|
mode: "background",
|
|
23
23
|
agent: "worker",
|
|
24
24
|
model: "test/model",
|
|
@@ -42,7 +42,7 @@ function makeSnapshot(over: Partial<RecordSnapshot> = {}): RecordSnapshot {
|
|
|
42
42
|
mode: "background",
|
|
43
43
|
task: "t",
|
|
44
44
|
slug: "test-slug",
|
|
45
|
-
status: "
|
|
45
|
+
status: "closed",
|
|
46
46
|
eventLog: [],
|
|
47
47
|
turns: 1,
|
|
48
48
|
totalTokens: 10,
|
|
@@ -61,6 +61,8 @@ function makeService(over: Partial<SubagentService> = {}): SubagentService {
|
|
|
61
61
|
findRecord: vi.fn(() => undefined),
|
|
62
62
|
cancel: vi.fn(() => false),
|
|
63
63
|
collectRecords: vi.fn(() => [] as SubagentRecord[]),
|
|
64
|
+
// [perf] listHandler 逐项 enrich 会调 getFullRecord;mock 回 undefined → 调用方回退 light record
|
|
65
|
+
getFullRecord: vi.fn(() => undefined as SubagentRecord | undefined),
|
|
64
66
|
...over,
|
|
65
67
|
} as SubagentService;
|
|
66
68
|
}
|
|
@@ -171,13 +173,13 @@ describe("listHandler", () => {
|
|
|
171
173
|
|
|
172
174
|
it("item 8 字段齐全(含 duration 实时计算)", () => {
|
|
173
175
|
const records: SubagentRecord[] = [
|
|
174
|
-
{ id: "r1", agent: "w", status: "
|
|
176
|
+
{ id: "r1", agent: "w", status: "closed", mode: "background", startedAt: 1000, endedAt: 2500, turns: 2, totalTokens: 50, model: "m", thinkingLevel: "high", eventLog: [], sessionFile: "x.jsonl" },
|
|
175
177
|
];
|
|
176
178
|
const svc = makeService({ collectRecords: vi.fn(() => records) });
|
|
177
179
|
const r = listHandler(svc, { includeFinished: true });
|
|
178
180
|
const item = r.response.items[0];
|
|
179
181
|
expect(item).toMatchObject({
|
|
180
|
-
subagentId: "r1", agent: "w", status: "
|
|
182
|
+
subagentId: "r1", agent: "w", status: "closed", mode: "background",
|
|
181
183
|
duration: 1, model: "m", totalTokens: 50, sessionFile: "x.jsonl",
|
|
182
184
|
});
|
|
183
185
|
});
|
|
@@ -263,7 +265,7 @@ describe("cancelHandler", () => {
|
|
|
263
265
|
|
|
264
266
|
it("[S-19] id 在树内但已终态(磁盘可见 done)→ 仍用 may have finished 消息", async () => {
|
|
265
267
|
const done: SubagentRecord = {
|
|
266
|
-
id: "bg-done", agent: "w", status: "
|
|
268
|
+
id: "bg-done", agent: "w", status: "closed", mode: "background", startedAt: 1,
|
|
267
269
|
endedAt: 2, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [],
|
|
268
270
|
};
|
|
269
271
|
const svc = makeService({
|
|
@@ -275,10 +277,10 @@ describe("cancelHandler", () => {
|
|
|
275
277
|
|
|
276
278
|
it("已终态(cancel 返回 false)→ throw could not be cancelled", async () => {
|
|
277
279
|
const svc = makeService({
|
|
278
|
-
findRecord: vi.fn(() => makeSnapshot({ id: "bg-1", mode: "background", status: "
|
|
280
|
+
findRecord: vi.fn(() => makeSnapshot({ id: "bg-1", mode: "background", status: "closed" })),
|
|
279
281
|
cancel: vi.fn(() => false),
|
|
280
282
|
});
|
|
281
|
-
await expect(cancelHandler(svc, { subagentId: "bg-1" })).rejects.toThrow(/could not be cancelled.*status:
|
|
283
|
+
await expect(cancelHandler(svc, { subagentId: "bg-1" })).rejects.toThrow(/could not be cancelled.*status: closed/);
|
|
282
284
|
});
|
|
283
285
|
|
|
284
286
|
it("成功 → cancelled:true", async () => {
|
|
@@ -295,7 +297,7 @@ describe("cancelHandler", () => {
|
|
|
295
297
|
// re-query 到终态。覆盖 cancelHandler 的 re-query 分支(subagent-actions.ts:267-272)。
|
|
296
298
|
it("并发 CAS:两次 cancel 同一 id,第二次 CAS 失败 re-query 到终态", async () => {
|
|
297
299
|
const running = makeSnapshot({ id: "bg-cas", mode: "background", status: "running" });
|
|
298
|
-
const done = makeSnapshot({ id: "bg-cas", mode: "background", status: "
|
|
300
|
+
const done = makeSnapshot({ id: "bg-cas", mode: "background", status: "closed" });
|
|
299
301
|
|
|
300
302
|
let cancelCalls = 0;
|
|
301
303
|
const svc = makeService({
|
|
@@ -317,9 +319,9 @@ describe("cancelHandler", () => {
|
|
|
317
319
|
const r1 = await cancelHandler(svc, { subagentId: "bg-cas" });
|
|
318
320
|
expect(r1.response.cancelled).toBe(true);
|
|
319
321
|
|
|
320
|
-
// 第二次 cancel:CAS 失败,re-query 返回
|
|
322
|
+
// 第二次 cancel:CAS 失败,re-query 返回 closed(不是初始的 running)
|
|
321
323
|
await expect(cancelHandler(svc, { subagentId: "bg-cas" }))
|
|
322
|
-
.rejects.toThrow(/could not be cancelled.*status:
|
|
324
|
+
.rejects.toThrow(/could not be cancelled.*status: closed/);
|
|
323
325
|
|
|
324
326
|
// 验证 re-query 确实发生(3 次 findRecord:1 初始 + 1 初始 + 1 re-query)
|
|
325
327
|
expect(svc.findRecord).toHaveBeenCalledTimes(3);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* truncLine — indexOf 化(IF12/#18)byte-identical 快照 + 非 SGR ESC 行为锚定。
|
|
3
|
+
*
|
|
4
|
+
* fixture(__fixtures__/truncline.snapshot.json)由**改造前实现**生成落盘
|
|
5
|
+
* (本文件同 commit),断言重构后输出逐字节一致——含 OSC / 裸 \x1b / \x1b[K
|
|
6
|
+
* 非 SGR ESC 用例(等价定义点:非 SGR ESC 不构成文本段边界,按文本计宽进 result)。
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { truncLine } from "../../interface/format.ts";
|
|
15
|
+
|
|
16
|
+
interface SnapshotCase {
|
|
17
|
+
label: string;
|
|
18
|
+
maxWidth: number;
|
|
19
|
+
text: string;
|
|
20
|
+
expected: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadCases(): SnapshotCase[] {
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
return JSON.parse(
|
|
26
|
+
readFileSync(join(here, "__fixtures__", "truncline.snapshot.json"), "utf8"),
|
|
27
|
+
) as SnapshotCase[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("truncLine — indexOf 化 byte-identical 快照(IF12)", () => {
|
|
31
|
+
it("全部 fixture 用例(10k ANSI 混合行 / CJK / OSC / 裸 ESC / CSI-K / emoji)与改造前输出逐字节一致", () => {
|
|
32
|
+
const cases = loadCases();
|
|
33
|
+
expect(cases.length).toBeGreaterThanOrEqual(10);
|
|
34
|
+
for (const c of cases) {
|
|
35
|
+
expect(truncLine(c.text, c.maxWidth)).toBe(c.expected);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("10k 混合长行在多个截断宽度下均一致(fixture 内 maxWidth 之外再加宽度扫描)", () => {
|
|
40
|
+
const mixed = loadCases().find((c) => c.label === "sgr-mixed-10k")!;
|
|
41
|
+
for (const w of [8, 15, 30, 61, 100]) {
|
|
42
|
+
const expected = mixed.expected; // fixture 锁 maxWidth=60;其他宽度靠 oracle 断言
|
|
43
|
+
void expected;
|
|
44
|
+
// 非 fixture 宽度用行为断言:输出含省略号且无裸换行
|
|
45
|
+
const out = truncLine(mixed.text, w);
|
|
46
|
+
expect(out).toContain("…");
|
|
47
|
+
expect(out).not.toContain("\n");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("truncLine — 非 SGR ESC 行为(等价定义点,显式锚定)", () => {
|
|
53
|
+
it("OSC 序列(\\x1b]0;title\\x07)按文本计宽进 result(不作为样式捕获/段边界)", () => {
|
|
54
|
+
// OSC 计入可见宽度(\x1b]0;title\x07 各字符占宽),随后文本继续同段累加
|
|
55
|
+
const out = truncLine("AB\x1b]0;title\x07CDEFGHIJKLMNOP", 10);
|
|
56
|
+
expect(out.endsWith("…")).toBe(true);
|
|
57
|
+
expect(out).not.toContain("\x1b]0;title\x07"); // 截断点在 OSC 之内或之前——
|
|
58
|
+
// 逐字节形态由 fixture(osc-title / osc-inside-truncation 用例)锚定
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("裸 \\x1b 后跟非 [ 字符:不匹配 SGR、并入文本段(不丢字符不死循环)", () => {
|
|
62
|
+
const out = truncLine("before \x1bZ after bare esc tail", 12);
|
|
63
|
+
expect(out.endsWith("…")).toBe(true);
|
|
64
|
+
// 输出是输入前缀(前 11 可见列 + …),未吞字符
|
|
65
|
+
expect(out.startsWith("before ")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("非 SGR CSI(\\x1b[K)与 SGR 混排且触发截断:\x1b[K 不进 activeStyles(无错位 reset)", () => {
|
|
69
|
+
const out = truncLine("\x1b[31mred\x1b[K\x1b[32mgreen tail past width", 10);
|
|
70
|
+
// SGR(31m/32m)被捕获;\x1b[K 按文本进 result——逐字节形态由 fixture
|
|
71
|
+
//(csi-k-mixed-sgr 用例)锚定;此处断言 SGR 样式重应用语义未回归
|
|
72
|
+
expect(out).toMatch(/\x1b\[31m/);
|
|
73
|
+
expect(out.endsWith("…\x1b[0m")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ESC 恰在串尾:无后续字符可判 SGR,按文本并入(end=flat.length)", () => {
|
|
77
|
+
const out = truncLine("text almost at width limit\x1b", 22);
|
|
78
|
+
expect(out.endsWith("…")).toBe(true);
|
|
79
|
+
expect(out).not.toContain("\x1b["); // 末尾裸 ESC 未触发 SGR 捕获
|
|
80
|
+
});
|
|
81
|
+
});
|