@mzzsfy/dsh-keep-working 0.0.0-stage → 0.1.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 +84 -2
- package/cordis.patch.yml +4 -0
- package/package.json +49 -4
- package/src/client.js +366 -0
- package/src/closure.mjs +182 -0
- package/src/config.mjs +103 -0
- package/src/engine.mjs +240 -0
- package/src/failure.mjs +84 -0
- package/src/index.js +259 -0
- package/src/offline.mjs +69 -0
- package/src/route-gate.mjs +29 -0
- package/src/route-guard.mjs +35 -0
- package/src/scan.mjs +425 -0
- package/src/session-kind.mjs +7 -0
- package/src/settings.mjs +59 -0
- package/test/client-id.test.mjs +34 -0
- package/test/client-panel.test.mjs +101 -0
- package/test/closure.test.mjs +302 -0
- package/test/config.test.mjs +24 -0
- package/test/engine.test.mjs +436 -0
- package/test/failure.test.mjs +81 -0
- package/test/offline.test.mjs +85 -0
- package/test/prompt-section.test.mjs +25 -0
- package/test/route-gate.test.mjs +57 -0
- package/test/route-guard.test.mjs +51 -0
- package/test/route.test.mjs +210 -0
- package/test/scan.test.mjs +685 -0
- package/test/settings.test.mjs +52 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
BLOCKED_PREFIX,
|
|
6
|
+
DONE_MARKER,
|
|
7
|
+
FILE_WRITE_TOOLS,
|
|
8
|
+
assistantText,
|
|
9
|
+
closureVerdict,
|
|
10
|
+
hasVisibleOutput,
|
|
11
|
+
humanInitiatedTurn,
|
|
12
|
+
lastAssistantText,
|
|
13
|
+
matchMarker,
|
|
14
|
+
turnInitiator,
|
|
15
|
+
userTookOver,
|
|
16
|
+
} from '../src/closure.mjs'
|
|
17
|
+
|
|
18
|
+
const TURN = 7
|
|
19
|
+
|
|
20
|
+
const event = (seq, type, data) => ({ seq, time: seq, type, data })
|
|
21
|
+
|
|
22
|
+
const turnStart = (seq, turn, reason) =>
|
|
23
|
+
event(seq, 'turn/start', reason === undefined ? { turn } : { turn, reason })
|
|
24
|
+
|
|
25
|
+
const turnEnd = (seq, turn, reason = { kind: 'completed' }) =>
|
|
26
|
+
event(seq, 'turn/end', { turn, reason })
|
|
27
|
+
|
|
28
|
+
const assistant = (seq, turn, text) =>
|
|
29
|
+
event(seq, 'assistant/message', {
|
|
30
|
+
turn,
|
|
31
|
+
step: 1,
|
|
32
|
+
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
|
33
|
+
stream: [],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const assistantToolOnly = (seq, turn) =>
|
|
37
|
+
event(seq, 'assistant/message', {
|
|
38
|
+
turn,
|
|
39
|
+
step: 1,
|
|
40
|
+
message: {
|
|
41
|
+
role: 'assistant',
|
|
42
|
+
content: [{ type: 'tool-call', callId: 'c1', name: 'read', arguments: '{}' }],
|
|
43
|
+
},
|
|
44
|
+
stream: [],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const toolCall = (seq, turn, name) =>
|
|
48
|
+
event(seq, 'tool/call', { turn, step: 1, callId: `c-${seq}`, name, arguments: '{}' })
|
|
49
|
+
|
|
50
|
+
const presented = (seq, turn) =>
|
|
51
|
+
event(seq, 'deliverables/presented', { turn, callId: `c-${seq}`, files: [{ path: 'out.md' }] })
|
|
52
|
+
|
|
53
|
+
const userMessage = (seq, kind = 'user') =>
|
|
54
|
+
event(seq, 'user/message', { id: `m-${seq}`, source: { kind }, content: [{ type: 'text', text: 'go' }] })
|
|
55
|
+
|
|
56
|
+
test('marker 只认独占末行,DONE 精确、BLOCKED 前缀', () => {
|
|
57
|
+
assert.equal(matchMarker('done\n(TASK_DONE)'), 'done')
|
|
58
|
+
assert.equal(matchMarker('done\n (TASK_DONE) '), 'done')
|
|
59
|
+
assert.equal(matchMarker('done\n`(TASK_DONE)`'), 'done')
|
|
60
|
+
assert.equal(matchMarker('done\n(TASK_DONE).'), 'done')
|
|
61
|
+
assert.equal(matchMarker('done\n(TASK_DONE)。'), 'done')
|
|
62
|
+
assert.equal(matchMarker('(TASK_BLOCKED: 缺依赖)'), 'blocked')
|
|
63
|
+
assert.equal(matchMarker('(TASK_DONE) 后面还有话'), null)
|
|
64
|
+
assert.equal(matchMarker('提到 (TASK_DONE) 但不在末行\n结论如上'), null)
|
|
65
|
+
assert.equal(matchMarker(''), null)
|
|
66
|
+
assert.equal(matchMarker(undefined), null)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('assistantText 拼接文本块,忽略工具块', () => {
|
|
70
|
+
assert.equal(assistantText({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }), 'ab')
|
|
71
|
+
assert.equal(assistantText({ content: [{ type: 'tool-call' }] }), '')
|
|
72
|
+
assert.equal(assistantText(undefined), '')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('lastAssistantText 跳过无文本的 assistant 消息', () => {
|
|
76
|
+
const events = [turnStart(0, TURN), assistant(1, TURN, '先说明'), assistantToolOnly(2, TURN)]
|
|
77
|
+
assert.equal(lastAssistantText(events, TURN), '先说明')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('S1 有文件写入但无 present 且无标记:未结案', () => {
|
|
81
|
+
const events = [turnStart(0, TURN), userMessage(1), toolCall(2, TURN, 'write'), assistant(3, TURN, '改好了')]
|
|
82
|
+
const verdict = closureVerdict(events, TURN)
|
|
83
|
+
assert.equal(verdict.closed, false)
|
|
84
|
+
assert.equal(verdict.reason, 'file-write-requires-present')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('S2 无文件写入且末行 DONE:结案', () => {
|
|
88
|
+
const events = [turnStart(0, TURN), userMessage(1), assistant(2, TURN, '结论\n(TASK_DONE)')]
|
|
89
|
+
const verdict = closureVerdict(events, TURN)
|
|
90
|
+
assert.equal(verdict.closed, true)
|
|
91
|
+
assert.equal(verdict.reason, 'marker')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('S3 present 之后又写文件:present 作废,未结案', () => {
|
|
95
|
+
const events = [
|
|
96
|
+
turnStart(0, TURN),
|
|
97
|
+
userMessage(1),
|
|
98
|
+
toolCall(2, TURN, 'write'),
|
|
99
|
+
presented(3, TURN),
|
|
100
|
+
toolCall(4, TURN, 'edit'),
|
|
101
|
+
assistant(5, TURN, '再改一下'),
|
|
102
|
+
]
|
|
103
|
+
const verdict = closureVerdict(events, TURN)
|
|
104
|
+
assert.equal(verdict.closed, false)
|
|
105
|
+
assert.equal(verdict.reason, 'file-write-requires-present')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('S4 有文件写入且 present 有效:结案', () => {
|
|
109
|
+
const events = [turnStart(0, TURN), userMessage(1), toolCall(2, TURN, 'write'), presented(3, TURN), assistant(4, TURN, '交付')]
|
|
110
|
+
const verdict = closureVerdict(events, TURN)
|
|
111
|
+
assert.equal(verdict.closed, true)
|
|
112
|
+
assert.equal(verdict.reason, 'present')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('S5 无文件写入且末行 BLOCKED:结案', () => {
|
|
116
|
+
const events = [turnStart(0, TURN), userMessage(1), assistant(2, TURN, '(TASK_BLOCKED: 缺 API)')]
|
|
117
|
+
const verdict = closureVerdict(events, TURN)
|
|
118
|
+
assert.equal(verdict.closed, true)
|
|
119
|
+
assert.equal(verdict.reason, 'marker')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('S6 有文件写入时标记无效:未结案', () => {
|
|
123
|
+
const events = [turnStart(0, TURN), userMessage(1), toolCall(2, TURN, 'write'), assistant(3, TURN, '完成\n(TASK_DONE)')]
|
|
124
|
+
const verdict = closureVerdict(events, TURN)
|
|
125
|
+
assert.equal(verdict.closed, false)
|
|
126
|
+
assert.equal(verdict.reason, 'file-write-requires-present')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('S7 无本回合写入但 present 存在:结案', () => {
|
|
130
|
+
const events = [turnStart(0, TURN), userMessage(1), presented(2, TURN), assistant(3, TURN, '交付')]
|
|
131
|
+
const verdict = closureVerdict(events, TURN)
|
|
132
|
+
assert.equal(verdict.closed, true)
|
|
133
|
+
assert.equal(verdict.reason, 'present')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('S8 无任何信号:未结案', () => {
|
|
137
|
+
const events = [turnStart(0, TURN), userMessage(1), assistant(2, TURN, '我看看')]
|
|
138
|
+
const verdict = closureVerdict(events, TURN)
|
|
139
|
+
assert.equal(verdict.closed, false)
|
|
140
|
+
assert.equal(verdict.reason, 'no-signal')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('S9 其它回合的 present 不参与判定', () => {
|
|
144
|
+
const events = [presented(1, TURN - 1), turnStart(2, TURN), userMessage(3), assistant(4, TURN, '好')]
|
|
145
|
+
const verdict = closureVerdict(events, TURN)
|
|
146
|
+
assert.equal(verdict.closed, false)
|
|
147
|
+
assert.equal(verdict.reason, 'no-signal')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('S10 turn/end 之后的事件不参与判定', () => {
|
|
151
|
+
const events = [turnStart(0, TURN), userMessage(1), assistant(2, TURN, '半成品'), turnEnd(3, TURN), presented(4, TURN)]
|
|
152
|
+
const verdict = closureVerdict(events, TURN)
|
|
153
|
+
assert.equal(verdict.closed, false)
|
|
154
|
+
assert.equal(verdict.reason, 'no-signal')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('hasVisibleOutput 只认文本内容', () => {
|
|
158
|
+
assert.equal(hasVisibleOutput([assistantToolOnly(1, TURN)]), false)
|
|
159
|
+
assert.equal(hasVisibleOutput([assistant(1, TURN, '有话说')]), true)
|
|
160
|
+
assert.equal(hasVisibleOutput([]), false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('turnInitiator 跳过非真人来源取真人消息', () => {
|
|
164
|
+
const events = [turnStart(1, TURN), userMessage(2, 'goal'), userMessage(3, 'user')]
|
|
165
|
+
assert.equal(turnInitiator(events, TURN), 'user')
|
|
166
|
+
assert.equal(turnInitiator([turnStart(1, TURN)], TURN), undefined)
|
|
167
|
+
assert.equal(turnInitiator([turnStart(1, TURN), userMessage(2, 'goal')], TURN), undefined, '只有 agent 来源不算真人发起')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('文件写入工具集合覆盖宿主写入面', () => {
|
|
171
|
+
for (const name of ['write', 'edit', 'apply_patch', 'str_replace_editor']) {
|
|
172
|
+
assert.equal(FILE_WRITE_TOOLS.has(name), true, name)
|
|
173
|
+
}
|
|
174
|
+
assert.equal(FILE_WRITE_TOOLS.has('read'), false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('导出标记字面量稳定', () => {
|
|
178
|
+
assert.equal(DONE_MARKER, '(TASK_DONE)')
|
|
179
|
+
assert.equal(BLOCKED_PREFIX, '(TASK_BLOCKED')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// 宿主先注入上下文(agent-instructions 等)再投递真人消息,首条 user/message 不是真人
|
|
183
|
+
const injected = (seq, kind = 'agent-instructions') =>
|
|
184
|
+
({
|
|
185
|
+
seq,
|
|
186
|
+
time: 1_000,
|
|
187
|
+
type: 'user/message',
|
|
188
|
+
data: { id: `inj-${seq}`, source: { kind }, content: [{ type: 'text', text: 'AGENTS.md' }] },
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('注入上下文前置时仍识别真人发起', () => {
|
|
192
|
+
const events = [
|
|
193
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
194
|
+
injected(2),
|
|
195
|
+
userMessage(3),
|
|
196
|
+
assistant(4, 1, '做完了'),
|
|
197
|
+
]
|
|
198
|
+
assert.equal(turnInitiator(events, 1), 'user')
|
|
199
|
+
assert.equal(humanInitiatedTurn(events, 1, new Set()), true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('注入上下文之后的真人消息不算用户接管', () => {
|
|
203
|
+
const events = [
|
|
204
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
205
|
+
injected(2),
|
|
206
|
+
userMessage(3),
|
|
207
|
+
assistant(4, 1, '做完了'),
|
|
208
|
+
]
|
|
209
|
+
assert.equal(userTookOver(events, 1, new Set()), false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('注入上下文加真人插话仍判接管', () => {
|
|
213
|
+
const events = [
|
|
214
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
215
|
+
injected(2),
|
|
216
|
+
userMessage(3),
|
|
217
|
+
userMessage(4),
|
|
218
|
+
assistant(5, 1, '做完了'),
|
|
219
|
+
]
|
|
220
|
+
assert.equal(userTookOver(events, 1, new Set()), true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('只有注入上下文没有真人消息时不判真人发起', () => {
|
|
224
|
+
const events = [
|
|
225
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
226
|
+
injected(2),
|
|
227
|
+
assistant(3, 1, '收到'),
|
|
228
|
+
]
|
|
229
|
+
assert.equal(turnInitiator(events, 1), undefined)
|
|
230
|
+
assert.equal(humanInitiatedTurn(events, 1, new Set()), false)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('失败的写不算改过文件', () => {
|
|
234
|
+
const events = [
|
|
235
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
236
|
+
userMessage(2),
|
|
237
|
+
{ seq: 3, time: 1_000, type: 'tool/call', data: { turn: 1, step: 1, callId: 'c1', name: 'write', arguments: '{}' } },
|
|
238
|
+
{ seq: 4, time: 1_000, type: 'tool/result', data: { turn: 1, step: 1, message: { toolCallId: 'c1', isError: true, content: [] } } },
|
|
239
|
+
assistant(5, 1, '写不了\n(TASK_DONE)'),
|
|
240
|
+
{ seq: 6, time: 1_000, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
241
|
+
]
|
|
242
|
+
assert.equal(closureVerdict(events, 1).closed, true, '写失败没有产物,应允许标记结案')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('编辑器 view 子命令不算改过文件', () => {
|
|
246
|
+
const events = [
|
|
247
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
248
|
+
userMessage(2),
|
|
249
|
+
{
|
|
250
|
+
seq: 3,
|
|
251
|
+
time: 1_000,
|
|
252
|
+
type: 'tool/call',
|
|
253
|
+
data: { turn: 1, step: 1, callId: 'c1', name: 'str_replace_editor', arguments: '{"command":"view","path":"a.ts"}' },
|
|
254
|
+
},
|
|
255
|
+
assistant(4, 1, '看完了\n(TASK_DONE)'),
|
|
256
|
+
{ seq: 5, time: 1_000, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
257
|
+
]
|
|
258
|
+
assert.equal(closureVerdict(events, 1).closed, true)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('成功的写仍要求 present', () => {
|
|
262
|
+
const events = [
|
|
263
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
264
|
+
userMessage(2),
|
|
265
|
+
{ seq: 3, time: 1_000, type: 'tool/call', data: { turn: 1, step: 1, callId: 'c1', name: 'edit', arguments: '{}' } },
|
|
266
|
+
{ seq: 4, time: 1_000, type: 'tool/result', data: { turn: 1, step: 1, message: { toolCallId: 'c1', isError: false, content: [] } } },
|
|
267
|
+
assistant(5, 1, '改好了'),
|
|
268
|
+
{ seq: 6, time: 1_000, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
269
|
+
]
|
|
270
|
+
assert.equal(closureVerdict(events, 1).closed, false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('成功的写不因同回合其它工具失败被剔除', () => {
|
|
274
|
+
const events = [
|
|
275
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
276
|
+
userMessage(2),
|
|
277
|
+
{ seq: 3, time: 1_000, type: 'tool/call', data: { turn: 1, step: 1, callId: 'c1', name: 'edit', arguments: '{}' } },
|
|
278
|
+
{ seq: 4, time: 1_000, type: 'tool/result', data: { turn: 1, step: 1, message: { toolCallId: 'c1', isError: false, content: [] } } },
|
|
279
|
+
{ seq: 5, time: 1_000, type: 'tool/call', data: { turn: 1, step: 2, callId: 'c2', name: 'bash', arguments: '{}' } },
|
|
280
|
+
{ seq: 6, time: 1_000, type: 'tool/result', data: { turn: 1, step: 2, message: { toolCallId: 'c2', isError: true, content: [] } } },
|
|
281
|
+
assistant(7, 1, '改好了\n(TASK_BLOCKED: 测试失败)'),
|
|
282
|
+
{ seq: 8, time: 1_000, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
283
|
+
]
|
|
284
|
+
assert.equal(closureVerdict(events, 1).closed, false, '成功的写仍要求 present')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('present 之后又写文件且随后工具失败仍判未结案', () => {
|
|
288
|
+
const events = [
|
|
289
|
+
{ seq: 1, time: 1_000, type: 'turn/start', data: { turn: 1 } },
|
|
290
|
+
userMessage(2),
|
|
291
|
+
{ seq: 3, time: 1_000, type: 'tool/call', data: { turn: 1, step: 1, callId: 'c1', name: 'write', arguments: '{}' } },
|
|
292
|
+
{ seq: 4, time: 1_000, type: 'tool/result', data: { turn: 1, step: 1, message: { toolCallId: 'c1', isError: false, content: [] } } },
|
|
293
|
+
{ seq: 5, time: 1_000, type: 'deliverables/presented', data: { turn: 1 } },
|
|
294
|
+
{ seq: 6, time: 1_000, type: 'tool/call', data: { turn: 1, step: 2, callId: 'c2', name: 'edit', arguments: '{}' } },
|
|
295
|
+
{ seq: 7, time: 1_000, type: 'tool/result', data: { turn: 1, step: 2, message: { toolCallId: 'c2', isError: false, content: [] } } },
|
|
296
|
+
{ seq: 8, time: 1_000, type: 'tool/call', data: { turn: 1, step: 2, callId: 'c3', name: 'bash', arguments: '{}' } },
|
|
297
|
+
{ seq: 9, time: 1_000, type: 'tool/result', data: { turn: 1, step: 2, message: { toolCallId: 'c3', isError: true, content: [] } } },
|
|
298
|
+
assistant(10, 1, '改完了'),
|
|
299
|
+
{ seq: 11, time: 1_000, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
300
|
+
]
|
|
301
|
+
assert.equal(closureVerdict(events, 1).closed, false, 'present 被后续写作废')
|
|
302
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 配置归一化:面板可填任意正整数,时长没有上界等于把恢复实际关掉
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
|
|
5
|
+
import { resolveConfig } from '../src/config.mjs'
|
|
6
|
+
|
|
7
|
+
test('时长字段超上界按上界截断', () => {
|
|
8
|
+
const config = resolveConfig({
|
|
9
|
+
graceMs: 999_999_999,
|
|
10
|
+
cooldownMs: 999_999_999,
|
|
11
|
+
backoffMaxMs: 999_999_999,
|
|
12
|
+
freshMs: 999_999_999,
|
|
13
|
+
})
|
|
14
|
+
assert.equal(config.graceMs, 5 * 60 * 1000)
|
|
15
|
+
assert.equal(config.cooldownMs, 60 * 60 * 1000)
|
|
16
|
+
assert.equal(config.backoffMaxMs, 24 * 60 * 60 * 1000)
|
|
17
|
+
assert.equal(config.freshMs, 7 * 24 * 60 * 60 * 1000)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('非法时长回落默认而不是归零', () => {
|
|
21
|
+
const config = resolveConfig({ graceMs: -1, freshMs: Number.NaN })
|
|
22
|
+
assert.ok(config.graceMs >= 0)
|
|
23
|
+
assert.ok(config.freshMs > 0)
|
|
24
|
+
})
|