Run one model request + tool execution round. Returns true when the turn is over.
()
| 613 | |
| 614 | /** Run one model request + tool execution round. Returns true when the turn is over. */ |
| 615 | private async runStep(): Promise<boolean> { |
| 616 | const { onEvent } = this.options.callbacks |
| 617 | const signal = this.abortController!.signal |
| 618 | |
| 619 | let assistantText = "" |
| 620 | // A reasoning segment is "open" from its first delta until visible content |
| 621 | // (text or a tool call) begins. We emit reasoning-done at that transition so |
| 622 | // "Thought for Ns" reflects only the thinking time — not the answer that |
| 623 | // follows — and the live "Thinking" block stops before the answer streams. |
| 624 | // A fresh segment can re-open if the model interleaves reasoning with content. |
| 625 | let reasoningOpen = false |
| 626 | let reasoningStart = 0 |
| 627 | let reasoningDetails: unknown |
| 628 | // Once a reasoning-done row is committed to the transcript we can't roll it |
| 629 | // back, so a mid-stream retry after that point isn't clean. |
| 630 | let reasoningRowCommitted = false |
| 631 | const finalizeReasoning = () => { |
| 632 | if (reasoningOpen) { |
| 633 | reasoningOpen = false |
| 634 | reasoningRowCommitted = true |
| 635 | onEvent({ type: "reasoning-done", durationMs: Date.now() - reasoningStart }) |
| 636 | } |
| 637 | } |
| 638 | const toolCallsByIndex = new Map<number, PendingToolCall>() |
| 639 | let nextSyntheticIndex = 10000 |
| 640 | |
| 641 | // Roll back this step's partial output so streamWithRetry can restart a |
| 642 | // dropped stream mid-flight. Tools only run after the stream completes, so |
| 643 | // nothing irreversible has happened yet; the one thing we can't undo is an |
| 644 | // already-committed reasoning row, so we decline the restart in that case. |
| 645 | const rollbackForRetry = (): boolean => { |
| 646 | if (reasoningRowCommitted) return false |
| 647 | assistantText = "" |
| 648 | reasoningOpen = false |
| 649 | reasoningStart = 0 |
| 650 | reasoningDetails = undefined |
| 651 | toolCallsByIndex.clear() |
| 652 | nextSyntheticIndex = 10000 |
| 653 | onEvent({ type: "stream-reset" }) |
| 654 | return true |
| 655 | } |
| 656 | |
| 657 | const stream = this.streamWithRetry( |
| 658 | () => this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(this.mcp), signal), |
| 659 | signal, |
| 660 | rollbackForRetry, |
| 661 | ) |
| 662 | |
| 663 | for await (const chunk of stream) { |
| 664 | if (signal.aborted) throw new DOMException("aborted", "AbortError") |
| 665 | switch (chunk.type) { |
| 666 | case "text": |
| 667 | // Visible content begins — the reasoning phase (if any) is over. |
| 668 | finalizeReasoning() |
| 669 | assistantText += chunk.text |
| 670 | onEvent({ type: "text-delta", text: chunk.text }) |
| 671 | break |
| 672 | case "reasoning": |
no test coverage detected