@huai_xin/dsh-workbuddy 0.1.15 → 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', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.15",
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')
@@ -1014,20 +1051,14 @@ export class WorkBuddyAdapter {
1014
1051
  lastDelta = '工具调用'
1015
1052
  const fragment = call.function?.arguments ?? ''
1016
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.
1017
1058
  if (!block.announced) {
1018
1059
  if (!hasUsableToolName(block.name)) continue
1019
1060
  block.announced = true
1020
1061
  toolOrder.push(block.index)
1021
- yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
1022
- yield { type: 'tool-call-delta', index: block.index, id: callId, name: block.name, argumentsDelta: block.text }
1023
- continue
1024
- }
1025
- yield {
1026
- type: 'tool-call-delta',
1027
- index: block.index,
1028
- id: callId,
1029
- ...(block.name === undefined ? {} : { name: block.name }),
1030
- argumentsDelta: fragment
1031
1062
  }
1032
1063
  }
1033
1064
  if (data.usage !== undefined && data.usage !== null) {
@@ -1047,6 +1078,22 @@ export class WorkBuddyAdapter {
1047
1078
  }
1048
1079
  }
1049
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
1050
1097
  } finally {
1051
1098
  reader.releaseLock()
1052
1099
  }
@@ -1058,9 +1105,12 @@ export class WorkBuddyAdapter {
1058
1105
  // A call whose arguments never finished arriving cannot be executed: handed
1059
1106
  // over, it becomes a parse error in the transcript instead of the truncation
1060
1107
  // that actually happened. It is reported as `max-tokens` below instead, so
1061
- // 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.
1062
1110
  if (isTruncatedArguments(block.text)) continue
1063
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 }
1064
1114
  yield {
1065
1115
  type: 'block-end',
1066
1116
  index,
@@ -1138,15 +1188,15 @@ export class WorkBuddyAdapter {
1138
1188
  yield finishChunk({ kind: 'max-tokens' })
1139
1189
  return
1140
1190
  }
1141
- if (blockCount === 0) {
1142
- yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
1143
- return
1144
- }
1145
1191
  // `finish_reason` has more values than the three the OpenAI docs mention, and
1146
1192
  // the official CodeBuddy CLI maps the whole set (`mapFinishReasonToStopReason`
1147
1193
  // in its bundle): `refusal` / `content_filter` are their own outcome, not a
1148
1194
  // stop, and `error` means the provider gave up mid-answer. Mapping those to
1149
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.
1150
1200
  const refusalReason = finishReason === 'refusal' || finishReason === 'content_filter'
1151
1201
  if (refusalReason) {
1152
1202
  this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游拒绝/过滤了本次回答(finish_reason=${finishReason}),不重试`)
@@ -1170,6 +1220,17 @@ export class WorkBuddyAdapter {
1170
1220
  })
1171
1221
  return
1172
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
+ }
1173
1234
  const toolReason = finishReason === 'tool_calls' || finishReason === 'tool_use' || finishReason === 'function_call'
1174
1235
  const reason = toolReason || toolOrder.length > 0
1175
1236
  ? { kind: 'tool-calls' }
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