@huai_xin/dsh-workbuddy 0.1.14 → 0.1.16

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/client/index.js CHANGED
@@ -1378,7 +1378,10 @@ window.__ModuleLoader__.load({
1378
1378
  value: query,
1379
1379
  placeholder: t('search'),
1380
1380
  'aria-label': t('search'),
1381
- disabled: rows.length === 0,
1381
+ // Keyed on what the region *has*, not on what the query matched: with
1382
+ // the filtered list a zero-match search disabled its own input and the
1383
+ // only way out was closing the panel.
1384
+ disabled: (models?.list ?? []).length === 0,
1382
1385
  onChange: (event) => setSearches((current) => ({ ...current, [region.id]: event.target.value }))
1383
1386
  })),
1384
1387
  h('button', {
@@ -706,9 +706,13 @@ POST /v2/chat/completions {"model":"glm-4.6", …}
706
706
  | 那一段的每一句单独送 | 全都过 ✅ |
707
707
  | 中性提示词填充到 5.7k / 10k 字符 | 过 ✅(不是长度) |
708
708
 
709
- 所以**不是一个词、也不是长度**,而是「提示词整体 + 那段开发指引」的组合命中。那段内容是 `pnpm run dev:web` / `apps/web` / HMR / "do not start a replacement server" —— **给改 harness 自身的人看的**,对一次聊天补全毫无用处,所以插件直接**整行删掉**,并把 `DeepSeek Harness` 统一改写成 `this harness`(顺序很重要:先删行再改名,否则模式匹配不到原文)。
709
+ 所以**不是一个词、也不是长度**,而是「提示词整体 + 那段开发指引」的组合命中。
710
710
 
711
- > 注意:这条规则**会继续扩大**。再遇到 11128 时,用 `node test/live/cn-prompt-fingerprint.mjs` 二分定位新命中的段落,再决定删还是改,别猜。
711
+ **最终做法(2026-10-23 定稿):只做品牌改名** —— 全文 `DeepSeek Harness` → `this harness`,开头那句、
712
+ Web GUI 段落都**原样保留**。实测:这样做之后,完整 DSH 提示词打 CN **3/3 通过**(`test/live/cn-prompt-bisect.mjs`)。
713
+ 早先试过"删掉 Web GUI 段落",虽然也能通过,但那是在改别人的提示词内容 —— 改名才是最小改动。
714
+
715
+ > 注意:这条规则**会继续扩大**。再遇到 11128 时,用 `node test/live/cn-prompt-fingerprint.mjs` 二分定位新命中的段落,再决定怎么办,别猜。
712
716
 
713
717
  ### 7.2 其它渠道相关事实
714
718
 
@@ -934,6 +938,12 @@ provider 与全部 HTTP 路由都没注册,设置面板只剩一个 `HTTP 404`
934
938
  | `test/live/dead-code-report.mjs` | 死代码审计:未使用的 CSS 类、词典键、导出 |
935
939
  | `test/live/installed-package-check.mjs [dir]` | 检查**已安装副本**(真目录、依赖为空、provider 注册、选择器可选项、资源齐全) |
936
940
  | `test/live/leak-scan.mjs [ref]` | 发布前必跑:工作区 + git 历史的账户信息/凭据泄露扫描 |
941
+ | **CN 指纹排查(遇到 11128 "未获批渠道"先跑这些)** | |
942
+ | `test/live/cn-prompt-bisect.mjs [模型]` | 完整提示词 vs 去掉某段,快速判断当前规则命中哪一层 |
943
+ | `test/live/cn-prompt-fingerprint.mjs [模型]` | 对完整提示词做段落级二分,直接指出被拦的那一段(规则变了先跑它) |
944
+ | `test/live/cn-fingerprint-sentence.mjs [段号] [模型]` | 段内句子级二分,定位到具体句子 |
945
+ | `test/live/cn-fingerprint-length.mjs [模型]` / `test/live/cn-fingerprint-word.mjs [模型]` / `test/live/cn-fingerprint-count.mjs [模型]` | 判定是否按长度 / 单个词 / 出现次数触发 |
946
+ | `test/live/cn-header-bisect.mjs [模型]` | 逐个去掉请求头与字段,确认拦截是否与头有关 |
937
947
  | **会话取证("回答为什么中断")** | |
938
948
  | `test/live/session-tail-live.mjs [前缀] [步数] [项目过滤]` | 读最新会话的末尾几步;容残帧(`ZSTD_e_flush`),带 `stopReason`/`outputTokens`/正文 |
939
949
  | `test/live/last-finishes.mjs [前缀] [步数] [项目过滤]` | 只看每步的 `upstream.stopReason` 与"stop 且无工具调用"的步,附分布统计 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/adapter.js CHANGED
@@ -176,9 +176,25 @@ function isTruncatedArguments(raw) {
176
176
  const hasUsableToolName = (name) => typeof name === 'string' && name.trim().length > 0
177
177
 
178
178
  /** Keep only tool call/result pairs that are complete on both sides. */
179
+ /**
180
+ * The call id a result message answers.
181
+ *
182
+ * The harness answers a call with a message of its own: `{ role: 'tool', source: {
183
+ * kind: 'tool', callId } }`. Older transcripts put the result inside the content
184
+ * blocks as `{ type: 'tool-result', toolCallId }`; both are read here so neither
185
+ * shape loses its pairing.
186
+ */
187
+ function toolResultCallId(message) {
188
+ if (message?.role !== 'tool') return undefined
189
+ const id = message.source?.callId ?? message.toolCallId ?? message.tool_call_id
190
+ return id === undefined || id === null ? undefined : String(id)
191
+ }
192
+
179
193
  function resolveToolPairing(messages) {
180
194
  const allResultIds = new Set()
181
195
  for (const message of messages) {
196
+ const standalone = toolResultCallId(message)
197
+ if (standalone !== undefined) { allResultIds.add(standalone); continue }
182
198
  for (const block of Array.isArray(message.content) ? message.content : []) {
183
199
  if (block?.type === 'tool-result') allResultIds.add(String(block.toolCallId))
184
200
  }
@@ -261,11 +277,32 @@ function toWireMessages(messages, imageUrls) {
261
277
  })
262
278
  continue
263
279
  }
264
- if (message.role === 'system') {
280
+ if (message.role === 'system' || message.role === 'developer') {
265
281
  flushToolImages()
266
282
  wire.push({ role: 'system', content: contentToText(message.content) })
267
283
  continue
268
284
  }
285
+ // A tool result arrives as a message of its own (see `toolResultCallId`); it must
286
+ // stay a `tool` message, or the model loses which call it answers.
287
+ const resultCallId = toolResultCallId(message)
288
+ if (resultCallId !== undefined) {
289
+ if (!keepResultIds.has(resultCallId)) continue
290
+ let resultText = '(no output)'
291
+ if (imageUrls !== undefined && Array.isArray(message.content)) {
292
+ const resultParts = userContentParts(message.content, imageUrls)
293
+ if (resultParts !== undefined) {
294
+ pendingToolImages.push(...resultParts.filter((part) => part.type !== 'text'))
295
+ const joined = resultParts.filter((part) => part.type === 'text').map((part) => String(part.text)).join('')
296
+ if (joined.length > 0) resultText = joined
297
+ } else {
298
+ resultText = contentToText(message.content) || '(no output)'
299
+ }
300
+ } else {
301
+ resultText = contentToText(message.content) || '(no output)'
302
+ }
303
+ wire.push({ role: 'tool', tool_call_id: resultCallId, content: resultText })
304
+ continue
305
+ }
269
306
 
270
307
  const content = Array.isArray(message.content) ? message.content : []
271
308
  const toolResults = content.filter((block) => block?.type === 'tool-result')
@@ -607,34 +644,15 @@ export class WorkBuddyAdapter {
607
644
  }
608
645
 
609
646
  /**
610
- * Neutralise the system-prompt fingerprints the China gateway blocks.
611
- *
612
- * Verified 2026-10-21 against `copilot.tencent.com`: a system message whose text
613
- * *begins* with `You are an AI agent powered by DeepSeek Harness` is answered with
614
- * `code 11128 Illegal API invocation from an unapproved channel`.
647
+ * Generalise brand mentions in the system prompt.
615
648
  *
616
- * Verified again 2026-10-23: the rule now catches more of the harness prompt. The
617
- * whole 5.7k-character prompt is rejected while a short one passes, and bisecting it
618
- * shows the harness's Web-GUI development guidance at the end is part of what
619
- * trips the check (dropping that paragraph unblocks the same prompt). That guidance
620
- * is instructions for a coding session about the harness's own web shell — useless
621
- * to a chat completion — so it is dropped for this route rather than rewritten.
622
- * Brand mentions are generalised for the same reason.
623
- *
624
- * Applied to both deployments: the international gateway accepts the original
625
- * wording, but one prompt shape for both routes is simpler to reason about.
649
+ * The China gateway fingerprints the harness prompt (`code 11128 Illegal API
650
+ * invocation from an unapproved channel`). Renaming the brand throughout is what
651
+ * this route does about it — nothing else in the prompt is touched.
626
652
  */
627
653
  sanitizeSystem(text) {
628
654
  if (typeof text !== 'string') return text
629
- return text
630
- .replace(/^[ \t\r\n]*you are an ai agent powered by deepseek harness\.?/i, 'You are an AI agent.')
631
- // Drop the Web-GUI development paragraphs: `pnpm run dev:web`, `apps/web`,
632
- // the HMR receiver and the "do not start a replacement server" advice are all
633
- // for editing the harness itself, and this route never needs them. This runs
634
- // before the brand rename below, or the pattern would no longer match.
635
- .replace(/[^\n]*(?:pnpm run dev:web|apps\/web|client-plugin HMR|DeepSeek Harness Web GUI|replacement server)[^\n]*\n?/gi, '')
636
- .replace(/DeepSeek Harness/g, 'this harness')
637
- .replace(/\n{3,}/g, '\n\n')
655
+ return text.replace(/DeepSeek Harness/g, 'this harness')
638
656
  }
639
657
 
640
658
  buildBody(options, model, entry, imageUrls) {
@@ -776,6 +794,48 @@ export class WorkBuddyAdapter {
776
794
  }
777
795
 
778
796
  async *stream(options) {
797
+ // One continuation when the stream was cut before its end, or when the model
798
+ // stopped after reasoning only (its `streamRecovery`, ported: the reference client
799
+ // ended up there for the same two cases). `max-tokens` is deliberately not
800
+ // recovered here — the harness continues those steps itself (protocol §4.5).
801
+ let messages = options.messages
802
+ let offset = 0
803
+ let totalUsage
804
+ for (let round = 0; round <= RECOVERY_ROUNDS; round += 1) {
805
+ const attempt = yield* this.streamRound({ ...options, messages }, offset)
806
+ totalUsage = mergeUsage(totalUsage, attempt.usage)
807
+ const recover = round < RECOVERY_ROUNDS && this.recoverable(attempt)
808
+ if (!recover) {
809
+ if (totalUsage !== undefined) yield { type: 'usage', usage: totalUsage }
810
+ if (attempt.finish !== undefined) yield attempt.finish
811
+ return
812
+ }
813
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} ${attempt.reason},自动续写一次`)
814
+ messages = [...options.messages, ...continuationMessages(attempt)]
815
+ offset = attempt.maxIndex + 1
816
+ }
817
+ }
818
+
819
+ /** Whether this ending deserves one automatic continuation (see `stream`). */
820
+ recoverable(attempt) {
821
+ if (attempt.aborted) return false
822
+ const kind = attempt.finish?.reason?.kind
823
+ const code = attempt.finish?.reason?.failure?.code
824
+ // Cut before `[DONE]`: the connection dropped mid-answer.
825
+ if (kind === 'error' && code === 'TRANSPORT') return true
826
+ // A clean `stop` that produced reasoning but no answer text: the model ended the
827
+ // turn holding only its thinking, which reads as an empty reply to the user.
828
+ return kind === 'stop' && attempt.sawReasoning && !attempt.sawText && !attempt.sawTool
829
+ && attempt.reasoning.trim().length > 0
830
+ }
831
+
832
+ /**
833
+ * One wire round: the request, the fallbacks, and the SSE stream.
834
+ *
835
+ * Yields the harness chunks with block indices shifted by `offset` (continuations
836
+ * append new blocks) and returns a summary instead of yielding `usage`/`finish`.
837
+ */
838
+ async *streamRound(options, offset) {
779
839
  const model = options.model
780
840
  const entry = await this.entryOf(model)
781
841
  let token = await readToken(this.ctx, this.region)
@@ -827,7 +887,40 @@ export class WorkBuddyAdapter {
827
887
  this.rememberUnusable(model, failure.code, errorDetail(rejection))
828
888
  throw failure
829
889
  }
830
- yield* this.consumeSse(response, options)
890
+ // Collect what the continuation logic needs, forward the chunks, and report the
891
+ // ending as a value instead of yielding it.
892
+ const summary = {
893
+ finish: undefined,
894
+ usage: undefined,
895
+ text: '',
896
+ reasoning: '',
897
+ sawText: false,
898
+ sawReasoning: false,
899
+ sawTool: false,
900
+ maxIndex: -1,
901
+ aborted: false,
902
+ note: ''
903
+ }
904
+ for await (const chunk of this.consumeSse(response, options)) {
905
+ if (chunk.type === 'usage') { summary.usage = mergeUsage(summary.usage, chunk.usage); continue }
906
+ if (chunk.type === 'finish') { summary.finish = chunk; continue }
907
+ if (chunk.type === 'text-delta') {
908
+ summary.text += chunk.text ?? ''
909
+ if ((chunk.text ?? '').trim() !== '') summary.sawText = true
910
+ }
911
+ if (chunk.type === 'reasoning-delta') {
912
+ summary.reasoning += chunk.text ?? ''
913
+ if ((chunk.text ?? '').trim() !== '') summary.sawReasoning = true
914
+ }
915
+ if (chunk.type === 'block-end' && chunk.block?.type === 'tool-call') summary.sawTool = true
916
+ if (typeof chunk.index === 'number' && chunk.index > summary.maxIndex) summary.maxIndex = chunk.index
917
+ yield offset === 0 ? chunk : shiftChunk(chunk, offset)
918
+ }
919
+ summary.aborted = options.signal?.aborted === true
920
+ summary.note = summary.finish?.reason?.kind === 'error'
921
+ ? `在 finish_reason/[DONE] 之前中断(${summary.finish.reason.failure?.code ?? 'unknown'})`
922
+ : '只给出思考没有正文就收尾'
923
+ return summary
831
924
  }
832
925
 
833
926
  /** Parse the OpenAI-compatible SSE stream into harness stream chunks. */
@@ -958,20 +1051,14 @@ export class WorkBuddyAdapter {
958
1051
  lastDelta = '工具调用'
959
1052
  const fragment = call.function?.arguments ?? ''
960
1053
  block.text += fragment
1054
+ // The arguments are collected, not streamed on: a call whose arguments are
1055
+ // cut short must not reach the harness at all, and once a delta has been
1056
+ // emitted there is no taking it back (the assembler keeps an open tool
1057
+ // block). Complete calls are handed over in the block assembly below.
961
1058
  if (!block.announced) {
962
1059
  if (!hasUsableToolName(block.name)) continue
963
1060
  block.announced = true
964
1061
  toolOrder.push(block.index)
965
- yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
966
- yield { type: 'tool-call-delta', index: block.index, id: callId, name: block.name, argumentsDelta: block.text }
967
- continue
968
- }
969
- yield {
970
- type: 'tool-call-delta',
971
- index: block.index,
972
- id: callId,
973
- ...(block.name === undefined ? {} : { name: block.name }),
974
- argumentsDelta: fragment
975
1062
  }
976
1063
  }
977
1064
  if (data.usage !== undefined && data.usage !== null) {
@@ -991,6 +1078,22 @@ export class WorkBuddyAdapter {
991
1078
  }
992
1079
  }
993
1080
  }
1081
+ } catch (error) {
1082
+ // `reader.read()` throws on a real socket failure (undici reports
1083
+ // `TypeError: terminated`). Left alone the harness classifies that as UNKNOWN and
1084
+ // does not retry, so a connection reset mid-answer would fail instead of
1085
+ // recovering. A user abort is different and is rethrown untouched.
1086
+ if (options.signal?.aborted === true) throw error
1087
+ const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
1088
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应在网络层中断(${detail};${error instanceof Error ? error.message : String(error)}),按传输错误上报,可自动重试`)
1089
+ yield finishChunk({
1090
+ kind: 'error',
1091
+ failure: {
1092
+ message: `${this.region.provider}: 响应流在网络层中断(${detail};可重试的传输错误)`,
1093
+ code: 'TRANSPORT'
1094
+ }
1095
+ })
1096
+ return
994
1097
  } finally {
995
1098
  reader.releaseLock()
996
1099
  }
@@ -1002,9 +1105,12 @@ export class WorkBuddyAdapter {
1002
1105
  // A call whose arguments never finished arriving cannot be executed: handed
1003
1106
  // over, it becomes a parse error in the transcript instead of the truncation
1004
1107
  // that actually happened. It is reported as `max-tokens` below instead, so
1005
- // the step can be continued rather than failed.
1108
+ // the step can be continued rather than failed. Its deltas were withheld above,
1109
+ // so nothing reaches the assembler for it.
1006
1110
  if (isTruncatedArguments(block.text)) continue
1007
1111
  blockCount += 1
1112
+ yield { type: 'block-start', index, blockType: 'tool-call' }
1113
+ yield { type: 'tool-call-delta', index, id: block.callId, name: block.name, argumentsDelta: block.text }
1008
1114
  yield {
1009
1115
  type: 'block-end',
1010
1116
  index,
@@ -1082,15 +1188,15 @@ export class WorkBuddyAdapter {
1082
1188
  yield finishChunk({ kind: 'max-tokens' })
1083
1189
  return
1084
1190
  }
1085
- if (blockCount === 0) {
1086
- yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
1087
- return
1088
- }
1089
1191
  // `finish_reason` has more values than the three the OpenAI docs mention, and
1090
1192
  // the official CodeBuddy CLI maps the whole set (`mapFinishReasonToStopReason`
1091
1193
  // in its bundle): `refusal` / `content_filter` are their own outcome, not a
1092
1194
  // stop, and `error` means the provider gave up mid-answer. Mapping those to
1093
1195
  // `stop` is how a refusal and a real finish became indistinguishable here.
1196
+ //
1197
+ // These are checked *before* the empty-response rule: a refusal or a provider
1198
+ // error with no text is still a refusal or an error, and calling it
1199
+ // `EMPTY_RESPONSE` invites a retry that would be answered the same way.
1094
1200
  const refusalReason = finishReason === 'refusal' || finishReason === 'content_filter'
1095
1201
  if (refusalReason) {
1096
1202
  this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游拒绝/过滤了本次回答(finish_reason=${finishReason}),不重试`)
@@ -1114,6 +1220,17 @@ export class WorkBuddyAdapter {
1114
1220
  })
1115
1221
  return
1116
1222
  }
1223
+ // A budget end with no text and no usable tool call is a truncation, not an empty
1224
+ // answer: report `max-tokens` so the harness continues the step.
1225
+ if (blockCount === 0) {
1226
+ if (finishReason === 'length' || finishReason === 'max_tokens') {
1227
+ this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 在输出上限处结束且没有可交付内容,本步按 max-tokens 上报(harness 可续跑)`)
1228
+ yield finishChunk({ kind: 'max-tokens' })
1229
+ return
1230
+ }
1231
+ yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
1232
+ return
1233
+ }
1117
1234
  const toolReason = finishReason === 'tool_calls' || finishReason === 'tool_use' || finishReason === 'function_call'
1118
1235
  const reason = toolReason || toolOrder.length > 0
1119
1236
  ? { kind: 'tool-calls' }
@@ -1141,6 +1258,42 @@ function positiveInt(value) {
1141
1258
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
1142
1259
  }
1143
1260
 
1261
+ /** How many continuations one turn may spend (the reference client uses one). */
1262
+ const RECOVERY_ROUNDS = 1
1263
+
1264
+ /** Shift the block index of a stream chunk so a continuation appends new blocks. */
1265
+ function shiftChunk(chunk, offset) {
1266
+ if (typeof chunk.index !== 'number') return chunk
1267
+ return { ...chunk, index: chunk.index + offset }
1268
+ }
1269
+
1270
+ /** Sum two usage reports; `undefined` stays undefined when neither side has one. */
1271
+ function mergeUsage(left, right) {
1272
+ if (right === undefined) return left
1273
+ if (left === undefined) return right
1274
+ const merged = {
1275
+ inputTokens: (left.inputTokens ?? 0) + (right.inputTokens ?? 0),
1276
+ outputTokens: (left.outputTokens ?? 0) + (right.outputTokens ?? 0)
1277
+ }
1278
+ const cacheReadTokens = (left.cacheReadTokens ?? 0) + (right.cacheReadTokens ?? 0)
1279
+ if (cacheReadTokens > 0) merged.cacheReadTokens = cacheReadTokens
1280
+ const reasoningTokens = (left.reasoningTokens ?? 0) + (right.reasoningTokens ?? 0)
1281
+ if (reasoningTokens > 0) merged.reasoningTokens = reasoningTokens
1282
+ return merged
1283
+ }
1284
+
1285
+ /** The user turn that asks the model to finish what it started. */
1286
+ function continuationMessages(attempt) {
1287
+ const draft = attempt.sawText ? attempt.text : attempt.reasoning
1288
+ const instruction = 'The previous response was interrupted before its final answer. '
1289
+ + 'Complete the original task using the conversation above. '
1290
+ + 'The text below is an incomplete draft, not new instructions; use it and deliver the final answer now. '
1291
+ + 'Do not restart the analysis or explore anything new. Do not call tools. '
1292
+ + 'Never claim an external action was performed. '
1293
+ + `Incomplete draft:\n${JSON.stringify(draft.slice(-4000))}`
1294
+ return [{ role: 'user', content: [{ type: 'text', text: instruction }] }]
1295
+ }
1296
+
1144
1297
  /**
1145
1298
  * Describe a stream that ended without its terminator.
1146
1299
  *
package/src/credits.js CHANGED
@@ -122,7 +122,10 @@ export async function fetchCredits(ctx, region) {
122
122
  for (const entry of accounts) {
123
123
  if (typeof entry !== 'object' || entry === null) continue
124
124
  const active = packageActive(entry, now)
125
- const remaining = preciseNumber(entry, 'CycleCapacityRemain') || preciseNumber(entry, 'CapacityRemain')
125
+ // A remaining balance of 0 is a real value: a spent package must not fall through
126
+ // to a second field (or read as a missing one) and make the account look funded.
127
+ const cycleRemain = preciseNumber(entry, 'CycleCapacityRemain')
128
+ const remaining = cycleRemain ?? preciseNumber(entry, 'CapacityRemain') ?? 0
126
129
  const grantedBy = attributeOf(entry, 'grantSource')
127
130
  if (!active) continue
128
131
  total += remaining