dsh-cline-pass 0.2.4 → 0.2.5

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 (2) hide show
  1. package/lib/adapter.js +69 -14
  2. package/package.json +1 -1
package/lib/adapter.js CHANGED
@@ -249,31 +249,88 @@ function collectImageParts(blocks, images, target = []) {
249
249
  /**
250
250
  * Convert the harness conversation to wire messages.
251
251
  *
252
- * Tool results become standalone `{ role: 'tool' }` messages. A user message
253
- * carrying an image switches to the OpenAI content-part array; a text-only one
254
- * keeps the plain-string form.
252
+ * Tool results become standalone `{ role: 'tool' }` messages, in both harness
253
+ * shapes: the current one, where the result IS a `tool`-role message carrying
254
+ * `toolCallId`, and the one dsh < 0.2.0 produced, a user message wrapping a
255
+ * `tool-result` block. A user message carrying an image switches to the OpenAI
256
+ * content-part array; a text-only one keeps the plain-string form.
255
257
  *
256
258
  * An image nested in a tool result (read_image, a screenshot tool) has no
257
259
  * OpenAI `tool` wire spelling, so it rides a following user message instead —
258
- * the same shape the shipped adapters use.
260
+ * the same shape the shipped adapters use. Deferring that message until the
261
+ * next non-tool message keeps a parallel group's answers contiguous.
262
+ *
263
+ * `developer` becomes `system`. The harness uses that role for its own
264
+ * system-level notices — the tool-registry change it appends when the tool set
265
+ * moves — and the newer OpenAI spelling is not accepted by every
266
+ * OpenAI-compatible gateway, while `system` is. Sending it as a user turn (what
267
+ * an unhandled role fell through to) also misrepresents it as something the user
268
+ * said.
269
+ *
270
+ * The group guard below is the same rule stated once: an assistant message
271
+ * carrying `tool_calls` must be followed immediately by a `tool` message per id.
272
+ * Any other message that would land inside that run — a notice, a user turn, the
273
+ * image message a tool-result image needs — is held back until the group closes,
274
+ * because a gateway that validates the rule rejects the whole request with
275
+ * "insufficient tool messages following tool_calls message".
259
276
  */
260
277
  function serializeMessages(messages, images) {
261
278
  const wire = []
262
279
  let pendingToolImages = []
280
+ /** Call ids of the most recent assistant tool_calls still awaiting an answer. */
281
+ let awaiting = []
282
+ /** Messages held back so they cannot split the group; emitted in order. */
283
+ let deferred = []
284
+
285
+ const flushDeferred = () => {
286
+ if (deferred.length === 0) return
287
+ for (const held of deferred) wire.push(held)
288
+ deferred = []
289
+ }
290
+ /**
291
+ * Emit a non-tool message.
292
+ *
293
+ * A message that is not a tool answer cannot go out while a group is open, so
294
+ * it waits. With no group open this is a plain push, which is every ordinary
295
+ * conversation and keeps that output unchanged.
296
+ */
297
+ const emit = (message) => {
298
+ if (awaiting.length > 0) deferred.push(message)
299
+ else wire.push(message)
300
+ }
263
301
  const flushToolImages = () => {
264
302
  if (pendingToolImages.length === 0) return
265
- wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
303
+ const message = { role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] }
266
304
  pendingToolImages = []
305
+ emit(message)
267
306
  }
307
+ /** A tool answer: it closes its own id, and the group when it was the last. */
308
+ const answer = (callId, content) => {
309
+ wire.push({ role: 'tool', tool_call_id: callId, content: flattenText(content) || '(no output)' })
310
+ collectImageParts(content, images, pendingToolImages)
311
+ awaiting = awaiting.filter((id) => id !== callId)
312
+ if (awaiting.length === 0) flushDeferred()
313
+ }
314
+
268
315
  for (const message of messages) {
269
- if (message.role === 'system') {
316
+ if (message.role === 'system' || message.role === 'developer') {
270
317
  flushToolImages()
271
- wire.push({ role: 'system', content: flattenText(message.content) })
318
+ emit({ role: 'system', content: flattenText(message.content) })
272
319
  continue
273
320
  }
274
321
  if (message.role === 'assistant') {
275
322
  flushToolImages()
323
+ // A turn boundary. Anything still held belongs before the new turn, and an
324
+ // unanswered group from the previous turn can no longer be answered.
325
+ flushDeferred()
276
326
  wire.push(serializeAssistant(message))
327
+ awaiting = (message.content ?? []).filter((block) => block.type === 'tool-call').map((block) => block.id)
328
+ continue
329
+ }
330
+ // A harness tool result: the call identity is on the message. No flush here,
331
+ // so the answers to one assistant message's parallel calls stay adjacent.
332
+ if (message.role === 'tool') {
333
+ answer(message.toolCallId, message.content)
277
334
  continue
278
335
  }
279
336
  const toolResults = (message.content ?? []).filter((block) => block.type === 'tool-result')
@@ -286,18 +343,16 @@ function serializeMessages(messages, images) {
286
343
  const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
287
344
  if (hasImage || text.length > 0 || toolResults.length === 0) {
288
345
  flushToolImages()
289
- wire.push({ role: 'user', content: hasImage ? parts : text })
346
+ emit({ role: 'user', content: hasImage ? parts : text })
290
347
  }
291
348
  for (const result of toolResults) {
292
- wire.push({
293
- role: 'tool',
294
- tool_call_id: result.toolCallId,
295
- content: flattenText(result.content) || '(no output)',
296
- })
297
- collectImageParts(result.content, images, pendingToolImages)
349
+ answer(result.toolCallId, result.content)
298
350
  }
299
351
  }
300
352
  flushToolImages()
353
+ // A group the history never closed leaves nothing to wait for; whatever was
354
+ // held still belongs in the request, just after the answers that do exist.
355
+ flushDeferred()
301
356
  return wire
302
357
  }
303
358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",