diff --git a/docs/assets/tps-screenshot.png b/docs/assets/tps-screenshot.png new file mode 100644 index 000000000000..1916471bee09 Binary files /dev/null and b/docs/assets/tps-screenshot.png differ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e83b9abe98ae..1ef360c2ccf9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1,3 +1,27 @@ +import type { AssistantMessage, Part, ReasoningPart, TextPart, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import { + addDefaultParsers, + type BoxRenderable, + MacOSScrollAccel, + RGBA, + type ScrollAcceleration, + type ScrollBoxRenderable, + TextAttributes, +} from "@opentui/core" +import { type JSX, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { SplitBorder } from "@tui/component/border" +import { useCommandDialog } from "@tui/component/dialog-command" +import { Prompt, type PromptRef } from "@tui/component/prompt" +import { Spinner } from "@tui/component/spinner" +import { useKeybind } from "@tui/context/keybind" +import { useLocal } from "@tui/context/local" +import { useRoute, useRouteData } from "@tui/context/route" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { selectedForeground, useTheme } from "@tui/context/theme" +import { DialogConfirm } from "@tui/ui/dialog-confirm" +import { parsePatch } from "diff" +import path from "path" import { batch, createContext, @@ -12,72 +36,49 @@ import { useContext, } from "solid-js" import { Dynamic } from "solid-js/web" -import path from "path" -import { useRoute, useRouteData } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { SplitBorder } from "@tui/component/border" -import { Spinner } from "@tui/component/spinner" -import { selectedForeground, useTheme } from "@tui/context/theme" -import { - BoxRenderable, - ScrollBoxRenderable, - addDefaultParsers, - MacOSScrollAccel, - type ScrollAcceleration, - TextAttributes, - RGBA, -} from "@opentui/core" -import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" -import { useLocal } from "@tui/context/local" -import { Locale } from "@/util/locale" -import type { Tool } from "@/tool/tool" -import type { ReadTool } from "@/tool/read" -import type { WriteTool } from "@/tool/write" +import stripAnsi from "strip-ansi" +import { UI } from "@/cli/ui.ts" +import { calculateTPS, formatTPS, getMessageTPS } from "@/core/tokens" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import type { ApplyPatchTool } from "@/tool/apply_patch" import { BashTool } from "@/tool/bash" +import type { EditTool } from "@/tool/edit" import type { GlobTool } from "@/tool/glob" -import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" -import type { EditTool } from "@/tool/edit" -import type { ApplyPatchTool } from "@/tool/apply_patch" -import type { WebFetchTool } from "@/tool/webfetch" -import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" +import type { ReadTool } from "@/tool/read" import type { SkillTool } from "@/tool/skill" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import { useSDK } from "@tui/context/sdk" -import { useCommandDialog } from "@tui/component/dialog-command" -import { useKeybind } from "@tui/context/keybind" -import { Header } from "./header" -import { parsePatch } from "diff" -import { useDialog } from "../../ui/dialog" -import { TodoItem } from "../../component/todo-item" -import { DialogMessage } from "./dialog-message" -import type { PromptInfo } from "../../component/prompt/history" -import { DialogConfirm } from "@tui/ui/dialog-confirm" -import { DialogTimeline } from "./dialog-timeline" -import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" -import { DialogSessionRename } from "../../component/dialog-session-rename" -import { Sidebar } from "./sidebar" -import { Flag } from "@/flag/flag" -import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import type { TaskTool } from "@/tool/task" +import type { TodoWriteTool } from "@/tool/todo" +import type { Tool } from "@/tool/tool" +import type { WebFetchTool } from "@/tool/webfetch" +import type { WriteTool } from "@/tool/write" +import { Filesystem } from "@/util/filesystem" +import { Locale } from "@/util/locale" import parsers from "../../../../../../parsers-config.ts" -import { Clipboard } from "../../util/clipboard" -import { Toast, useToast } from "../../ui/toast" +import { DialogSessionRename } from "../../component/dialog-session-rename" +import type { PromptInfo } from "../../component/prompt/history" +import { TodoItem } from "../../component/todo-item" +import { useExit } from "../../context/exit" import { useKV } from "../../context/kv.tsx" +import { usePromptRef } from "../../context/prompt" +import { useDialog } from "../../ui/dialog" +import { DialogExportOptions } from "../../ui/dialog-export-options" +import { Toast, useToast } from "../../ui/toast" +import { Clipboard } from "../../util/clipboard" import { Editor } from "../../util/editor" -import stripAnsi from "strip-ansi" +import { formatTranscript } from "../../util/transcript" +import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" +import { DialogMessage } from "./dialog-message" +import { DialogTimeline } from "./dialog-timeline" import { Footer } from "./footer.tsx" -import { usePromptRef } from "../../context/prompt" -import { useExit } from "../../context/exit" -import { Filesystem } from "@/util/filesystem" -import { Global } from "@/global" +import { Header } from "./header" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" -import { DialogExportOptions } from "../../ui/dialog-export-options" -import { formatTranscript } from "../../util/transcript" -import { UI } from "@/cli/ui.ts" +import { Sidebar } from "./sidebar" addDefaultParsers(parsers.parsers) @@ -201,7 +202,7 @@ export function Session() { } }) - let lastSwitch: string | undefined = undefined + let lastSwitch: string | undefined sdk.event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return @@ -998,7 +999,7 @@ export function Session() { {(message, index) => ( - {(function () { + {(() => { const command = useCommandDialog() const [hover, setHover] = createSignal(false) const dialog = useDialog() @@ -1267,6 +1268,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) + const tpsResult = createMemo(() => { + return getMessageTPS({ + finish: props.message.finish, + tokens: props.message.tokens, + time: props.message.time, + }) + }) + return ( <> @@ -1317,6 +1326,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las · {Locale.duration(duration())} + + · {formatTPS(tpsResult()!)} + · interrupted diff --git a/packages/opencode/src/core/tokens/index.ts b/packages/opencode/src/core/tokens/index.ts new file mode 100644 index 000000000000..711321448649 --- /dev/null +++ b/packages/opencode/src/core/tokens/index.ts @@ -0,0 +1 @@ +export * from "./tps" diff --git a/packages/opencode/src/core/tokens/tps.test.ts b/packages/opencode/src/core/tokens/tps.test.ts new file mode 100644 index 000000000000..64a4c3b42842 --- /dev/null +++ b/packages/opencode/src/core/tokens/tps.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + calculateTPS, + DEFAULT_MIN_TPS_ELAPSED_MS, + formatTPS, + getMessageTPS, + isValidForTPS, + totalGeneratedTokens, +} from './tps' + +describe('totalGeneratedTokens', () => { + test('sums output and reasoning by default', () => { + expect(totalGeneratedTokens({ output: 100, reasoning: 50 })).toBe(150) + }) + + test('excludes reasoning when configured', () => { + expect(totalGeneratedTokens({ output: 100, reasoning: 50 }, false)).toBe(100) + }) + + test('handles zero tokens', () => { + expect(totalGeneratedTokens({ output: 0, reasoning: 0 })).toBe(0) + }) + + test('handles reasoning-only responses', () => { + expect(totalGeneratedTokens({ output: 0, reasoning: 50 })).toBe(50) + }) +}) + +describe('isValidForTPS', () => { + const validMessage = { + finish: 'stop' as const, + tokens: { output: 100, reasoning: 50 }, + time: { created: 1000, firstToken: 1100, completed: 2000 }, + } + + test('returns true for valid text response', () => { + expect(isValidForTPS(validMessage)).toBe(true) + }) + + test('returns false for summary messages', () => { + expect(isValidForTPS({ ...validMessage, summary: true })).toBe(false) + }) + + test('returns false for tool-call responses', () => { + expect(isValidForTPS({ ...validMessage, finish: 'tool-calls' })).toBe(false) + }) + + test('returns false for error responses', () => { + expect(isValidForTPS({ ...validMessage, finish: 'error' })).toBe(false) + }) + + test('returns false for unknown finish reasons', () => { + expect(isValidForTPS({ ...validMessage, finish: 'unknown' })).toBe(false) + }) + + test('returns false for null finish', () => { + expect(isValidForTPS({ ...validMessage, finish: null })).toBe(false) + }) + + test('returns false for undefined finish', () => { + expect(isValidForTPS({ ...validMessage, finish: undefined })).toBe(false) + }) + + test('returns false for zero tokens', () => { + expect( + isValidForTPS({ + ...validMessage, + tokens: { output: 0, reasoning: 0 }, + }), + ).toBe(false) + }) + + test('returns false for missing firstToken', () => { + expect( + isValidForTPS({ + ...validMessage, + time: { ...validMessage.time, firstToken: undefined }, + }), + ).toBe(false) + }) + + test('returns false for missing completed', () => { + expect( + isValidForTPS({ + ...validMessage, + time: { ...validMessage.time, completed: undefined }, + }), + ).toBe(false) + }) + + test('returns false for elapsed time below threshold', () => { + expect( + isValidForTPS({ + ...validMessage, + time: { + ...validMessage.time, + completed: validMessage.time.firstToken! + DEFAULT_MIN_TPS_ELAPSED_MS - 1, + }, + }), + ).toBe(false) + }) + + test('returns true for elapsed time at threshold', () => { + expect( + isValidForTPS({ + ...validMessage, + time: { + ...validMessage.time, + completed: validMessage.time.firstToken! + DEFAULT_MIN_TPS_ELAPSED_MS, + }, + }), + ).toBe(true) + }) + + test('respects custom minElapsedMs', () => { + expect( + isValidForTPS({ + ...validMessage, + time: { + ...validMessage.time, + completed: validMessage.time.firstToken! + 100, + }, + minElapsedMs: 50, + }), + ).toBe(true) + }) +}) + +describe('calculateTPS', () => { + test('calculates correct rate for 1 second', () => { + const result = calculateTPS(100, 1000)! + expect(result.rate).toBe(100) + expect(result.totalTokens).toBe(100) + expect(result.elapsedMs).toBe(1000) + expect(result.isValid).toBe(true) + }) + + test('calculates correct rate for 500ms', () => { + const result = calculateTPS(50, 500)! + expect(result.rate).toBe(100) + }) + + test('rounds to nearest integer', () => { + expect(calculateTPS(100, 333)!.rate).toBe(300) + expect(calculateTPS(100, 667)!.rate).toBe(150) + }) + + test('returns undefined for zero tokens', () => { + expect(calculateTPS(0, 1000)).toBeUndefined() + }) + + test('returns undefined for elapsed time below threshold', () => { + expect(calculateTPS(100, 249)).toBeUndefined() + }) + + test('returns undefined for negative elapsed time', () => { + expect(calculateTPS(100, -100)).toBeUndefined() + }) + + test('returns undefined for non-finite results', () => { + expect(calculateTPS(100, 0, 0)).toBeUndefined() + }) + + test('handles very high rates', () => { + // Use custom minElapsedMs to allow short durations + const result = calculateTPS(1000, 100, 50)! + expect(result.rate).toBe(10000) + }) + + test('handles Claude 3.5 typical rate (~45 tok/s)', () => { + const result = calculateTPS(180, 4000)! + expect(result.rate).toBe(45) + }) + + test('handles GPT-4 typical rate (~25 tok/s)', () => { + const result = calculateTPS(100, 4000)! + expect(result.rate).toBe(25) + }) + + test('handles local model typical rate (~120 tok/s)', () => { + const result = calculateTPS(600, 5000)! + expect(result.rate).toBe(120) + }) +}) + +describe('formatTPS', () => { + test('formats with locale string', () => { + expect(formatTPS({ rate: 1234, totalTokens: 100, elapsedMs: 1000, isValid: true })).toBe( + '1,234 tok/s', + ) + }) + + test('formats single digit', () => { + expect(formatTPS({ rate: 5, totalTokens: 10, elapsedMs: 2000, isValid: true })).toBe('5 tok/s') + }) + + test('formats large numbers', () => { + expect(formatTPS({ rate: 1234567, totalTokens: 1000, elapsedMs: 1000, isValid: true })).toBe( + '1,234,567 tok/s', + ) + }) +}) + +describe('getMessageTPS', () => { + test('returns full result for valid message', () => { + const message = { + finish: 'stop' as const, + tokens: { output: 150, reasoning: 50 }, + time: { created: 1000, firstToken: 1000, completed: 3000 }, + } + + const result = getMessageTPS(message) + expect(result).toBeDefined() + expect(result!.rate).toBe(100) + expect(result!.totalTokens).toBe(200) + expect(result!.elapsedMs).toBe(2000) + }) + + test('returns undefined for invalid message', () => { + const message = { + finish: 'tool-calls' as const, + tokens: { output: 0, reasoning: 0 }, + time: { created: 1000, firstToken: 1100, completed: 1200 }, + } + + expect(getMessageTPS(message)).toBeUndefined() + }) + + test('calculates output-only TPS', () => { + const message = { + finish: 'stop' as const, + tokens: { output: 100, reasoning: 0 }, + time: { created: 1000, firstToken: 1000, completed: 3000 }, + } + + const result = getMessageTPS(message) + expect(result).toBeDefined() + expect(result!.rate).toBe(50) + expect(result!.totalTokens).toBe(100) + }) +}) diff --git a/packages/opencode/src/core/tokens/tps.ts b/packages/opencode/src/core/tokens/tps.ts new file mode 100644 index 000000000000..ac1c5cff7e3c --- /dev/null +++ b/packages/opencode/src/core/tokens/tps.ts @@ -0,0 +1,83 @@ +export const DEFAULT_MIN_TPS_ELAPSED_MS = 250 +export const DEFAULT_INCLUDE_REASONING = true + +export interface TokenMetrics { + output: number + reasoning: number +} + +export interface TimestampMetrics { + created: number + firstToken?: number + completed?: number +} + +export interface TPSResult { + rate: number + totalTokens: number + elapsedMs: number + isValid: boolean +} + +export function totalGeneratedTokens(tokens: TokenMetrics, includeReasoning = DEFAULT_INCLUDE_REASONING): number { + return tokens.output + (includeReasoning ? tokens.reasoning : 0) +} + +export function isValidForTPS(msg: { + summary?: boolean + finish?: string | null + tokens: TokenMetrics + time: TimestampMetrics + minElapsedMs?: number +}): boolean { + if (msg.summary) return false + if (!msg.finish) return false + if (["tool-calls", "unknown", "error"].includes(msg.finish)) return false + + const totalTokens = totalGeneratedTokens(msg.tokens) + if (totalTokens <= 0) return false + + if (msg.time.firstToken === undefined || msg.time.completed === undefined) return false + + const elapsedMs = msg.time.completed - msg.time.firstToken + const minElapsedMs = msg.minElapsedMs ?? DEFAULT_MIN_TPS_ELAPSED_MS + + return elapsedMs >= minElapsedMs +} + +export function calculateTPS( + totalTokens: number, + elapsedMs: number, + minElapsedMs = DEFAULT_MIN_TPS_ELAPSED_MS, +): TPSResult | undefined { + if (totalTokens <= 0) return undefined + if (elapsedMs < minElapsedMs) return undefined + + const rate = totalTokens / (elapsedMs / 1000) + if (!Number.isFinite(rate) || rate < 0) return undefined + + return { + rate: Math.round(rate), + totalTokens, + elapsedMs, + isValid: true, + } +} + +export function formatTPS(result: TPSResult): string { + return `${result.rate.toLocaleString()} tok/s` +} + +export function getMessageTPS(msg: { + summary?: boolean + finish?: string | null + tokens: TokenMetrics + time: TimestampMetrics +}): TPSResult | undefined { + if (!isValidForTPS(msg)) return undefined + + const totalTokens = totalGeneratedTokens(msg.tokens) + const elapsedMs = msg.time.completed! - msg.time.firstToken! + + return calculateTPS(totalTokens, elapsedMs) +} diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 58f9af7cdbab..759d39c396ed 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -125,9 +125,17 @@ export namespace Ripgrep { const state = lazy(async () => { const system = Bun.which("rg") if (system) { - const stat = await fs.stat(system).catch(() => undefined) - if (stat?.isFile()) return { filepath: system } - log.warn("bun.which returned invalid rg path", { filepath: system }) + if (process.platform === "win32" && system.startsWith("/")) { + log.warn("ignoring POSIX rg path on Windows", { filepath: system }) + } else { + const stat = await fs.stat(system).catch(() => undefined) + if (stat?.isFile()) { + const probe = await $`${system} --version`.quiet().nothrow() + if (probe.exitCode === 0) return { filepath: system } + log.warn("bun.which returned unusable rg path", { filepath: system, exitCode: probe.exitCode }) + } + log.warn("bun.which returned invalid rg path", { filepath: system }) + } } const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227a..e745a2199b10 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,20 +1,20 @@ -import { BusEvent } from "@/bus/bus-event" -import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { Identifier } from "../id/id" -import { LSP } from "../lsp" +import type { SystemError } from "bun" +import { STATUS_CODES } from "http" +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" import { fn } from "@/util/fn" +import { iife } from "@/util/iife" import { Database, eq, desc, inArray } from "@/storage/db" import { MessageTable, PartTable } from "./session.sql" -import { ProviderTransform } from "@/provider/transform" -import { STATUS_CODES } from "http" -import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" -import { iife } from "@/util/iife" -import { type SystemError } from "bun" -import type { Provider } from "@/provider/provider" +import { Identifier } from "../id/id" +import { LSP } from "../lsp" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) @@ -393,6 +393,7 @@ export namespace MessageV2 { time: z.object({ created: z.number(), completed: z.number().optional(), + firstToken: z.number().optional(), }), error: z .discriminatedUnion("name", [ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e7532d20073b..08337437d2ad 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,20 +1,20 @@ -import { MessageV2 } from "./message-v2" -import { Log } from "@/util/log" -import { Identifier } from "@/id/id" -import { Session } from "." import { Agent } from "@/agent/agent" -import { Snapshot } from "@/snapshot" -import { SessionSummary } from "./summary" import { Bus } from "@/bus" -import { SessionRetry } from "./retry" -import { SessionStatus } from "./status" -import { Plugin } from "@/plugin" -import type { Provider } from "@/provider/provider" -import { LLM } from "./llm" import { Config } from "@/config/config" -import { SessionCompaction } from "./compaction" +import { Identifier } from "@/id/id" import { PermissionNext } from "@/permission/next" +import { Plugin } from "@/plugin" +import type { Provider } from "@/provider/provider" import { Question } from "@/question" +import { Snapshot } from "@/snapshot" +import { Log } from "@/util/log" +import { Session } from "." +import { SessionCompaction } from "./compaction" +import { LLM } from "./llm" +import { MessageV2 } from "./message-v2" +import { SessionRetry } from "./retry" +import { SessionStatus } from "./status" +import { SessionSummary } from "./summary" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -34,6 +34,16 @@ export namespace SessionProcessor { let blocked = false let attempt = 0 let needsCompaction = false + let firstOutputDeltaTimestamp: number | undefined + let lastOutputDeltaTimestamp: number | undefined + + const markOutputDeltaTimestamp = (now: number) => { + if (firstOutputDeltaTimestamp === undefined) { + firstOutputDeltaTimestamp = now + input.assistantMessage.time.firstToken = now + } + lastOutputDeltaTimestamp = now + } const result = { get message() { @@ -45,11 +55,13 @@ export namespace SessionProcessor { async process(streamInput: LLM.StreamInput) { log.info("process") needsCompaction = false + firstOutputDeltaTimestamp = undefined + lastOutputDeltaTimestamp = undefined const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined - let reasoningMap: Record = {} + const reasoningMap: Record = {} const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { @@ -80,6 +92,8 @@ export namespace SessionProcessor { case "reasoning-delta": if (value.id in reasoningMap) { + const now = Date.now() + markOutputDeltaTimestamp(now) const part = reasoningMap[value.id] part.text += value.text if (value.providerMetadata) part.metadata = value.providerMetadata @@ -108,7 +122,7 @@ export namespace SessionProcessor { } break - case "tool-input-start": + case "tool-input-start": { const part = await Session.updatePart({ id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), messageID: input.assistantMessage.id, @@ -124,9 +138,13 @@ export namespace SessionProcessor { }) toolcalls[value.id] = part as MessageV2.ToolPart break + } - case "tool-input-delta": + case "tool-input-delta": { + const now = Date.now() + markOutputDeltaTimestamp(now) break + } case "tool-input-end": break @@ -241,7 +259,7 @@ export namespace SessionProcessor { }) break - case "finish-step": + case "finish-step": { const usage = Session.getUsage({ model: input.model, usage: value.usage, @@ -249,7 +267,11 @@ export namespace SessionProcessor { }) input.assistantMessage.finish = value.finishReason input.assistantMessage.cost += usage.cost - input.assistantMessage.tokens = usage.tokens + input.assistantMessage.tokens.input += usage.tokens.input + input.assistantMessage.tokens.output += usage.tokens.output + input.assistantMessage.tokens.reasoning += usage.tokens.reasoning + input.assistantMessage.tokens.cache.read += usage.tokens.cache.read + input.assistantMessage.tokens.cache.write += usage.tokens.cache.write await Session.updatePart({ id: Identifier.ascending("part"), reason: value.finishReason, @@ -283,6 +305,7 @@ export namespace SessionProcessor { needsCompaction = true } break + } case "text-start": currentText = { @@ -301,6 +324,8 @@ export namespace SessionProcessor { case "text-delta": if (currentText) { + const now = Date.now() + markOutputDeltaTimestamp(now) currentText.text += value.text if (value.providerMetadata) currentText.metadata = value.providerMetadata await Session.updatePartDelta({ @@ -407,7 +432,7 @@ export namespace SessionProcessor { }) } } - input.assistantMessage.time.completed = Date.now() + input.assistantMessage.time.completed = lastOutputDeltaTimestamp ?? Date.now() await Session.updateMessage(input.assistantMessage) if (needsCompaction) return "compact" if (blocked) return "stop" diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e120..4214967d5f87 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -208,6 +208,7 @@ export type AssistantMessage = { time: { created: number completed?: number + firstToken?: number } error?: | ProviderAuthError @@ -577,35 +578,6 @@ export type EventPermissionReplied = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -677,6 +649,35 @@ export type EventQuestionRejected = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -958,11 +959,11 @@ export type Event = | EventMessagePartRemoved | EventPermissionAsked | EventPermissionReplied - | EventSessionStatus - | EventSessionIdle | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected + | EventSessionStatus + | EventSessionIdle | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85a1af9d70cc..81700abf57b3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6410,6 +6410,9 @@ }, "completed": { "type": "number" + }, + "firstToken": { + "type": "number" } }, "required": ["created"] @@ -7488,90 +7491,6 @@ }, "required": ["type", "properties"] }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -7717,6 +7636,90 @@ }, "required": ["type", "properties"] }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, "Event.session.compacted": { "type": "object", "properties": { @@ -8471,19 +8474,19 @@ "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.session.status" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.question.asked" + "$ref": "#/components/schemas/Event.question.rejected" }, { - "$ref": "#/components/schemas/Event.question.replied" + "$ref": "#/components/schemas/Event.session.status" }, { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/Event.session.idle" }, { "$ref": "#/components/schemas/Event.session.compacted"