Merge remote-tracking branch 'origin/main' into merge/main-into-dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/screens/settings-screen.tsx
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
This commit is contained in:
Mohamed Boudra
2026-04-08 11:54:46 +07:00
39 changed files with 1197 additions and 206 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.49",
"version": "0.1.50",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -61,8 +61,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.49",
"@getpaseo/relay": "0.1.49",
"@getpaseo/highlight": "0.1.50",
"@getpaseo/relay": "0.1.50",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -2205,6 +2205,10 @@ export class AgentManager {
void this.refreshRuntimeInfo(agent);
}
break;
case "usage_updated":
agent.lastUsage = event.usage;
this.emitState(agent);
break;
case "timeline":
// Skip provider-replayed user_message items during history hydration.
if (options?.fromHistory && event.item.type === "user_message") {
@@ -302,6 +302,7 @@ export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
| {
type: "turn_failed";
provider: AgentProvider;
@@ -251,6 +251,51 @@ describe("ProviderSnapshotManager", () => {
manager.destroy();
});
test("refresh during an in-flight refresh is a no-op", async () => {
const fetchModels = deferred<AgentModelDefinition[]>();
const fetchModes = deferred<AgentMode[]>();
const { registry, handles } = createRegistry([
createMockProvider({
provider: "codex",
fetchModels: async () => fetchModels.promise,
fetchModes: async () => fetchModes.promise,
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries));
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "loading" },
]);
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
expect(changes).toHaveLength(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
fetchModes.resolve([createMode("auto")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.2")],
modes: [createMode("auto")],
});
});
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
const codexModels = deferred<AgentModelDefinition[]>();
const { registry, handles } = createRegistry([
@@ -32,8 +32,7 @@ export class ProviderSnapshotManager {
const cwdKey = normalizeCwdKey(cwd);
const entries = this.snapshots.get(cwdKey);
if (!entries) {
const loadingEntries = this.createLoadingEntries();
this.snapshots.set(cwdKey, loadingEntries);
const loadingEntries = this.resetSnapshotToLoading(cwdKey);
void this.warmUp(cwd);
return entriesToArray(loadingEntries);
}
@@ -42,7 +41,11 @@ export class ProviderSnapshotManager {
refresh(cwd?: string): void {
const cwdKey = normalizeCwdKey(cwd);
this.snapshots.set(cwdKey, this.createLoadingEntries());
if (this.warmUps.has(cwdKey)) {
return;
}
this.resetSnapshotToLoading(cwdKey);
this.emitChange(cwdKey);
void this.warmUp(cwd);
}
@@ -170,6 +173,15 @@ export class ProviderSnapshotManager {
return created;
}
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
const snapshot = this.getOrCreateSnapshot(cwdKey);
snapshot.clear();
for (const [provider, entry] of this.createLoadingEntries()) {
snapshot.set(provider, entry);
}
return snapshot;
}
private getProviderIds(): AgentProvider[] {
return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]);
}
@@ -358,7 +358,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
}
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => {
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage(
@@ -388,7 +388,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
});
test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => {
test("contextWindowUsedTokens falls back to result usage when no task_progress was received", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
@@ -468,6 +468,124 @@ describe("ClaudeAgentSession context window usage", () => {
});
});
test("task_progress emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
},
uuid: "task-progress-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 999,
},
});
});
test("task_notification emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_notification",
uuid: "task-note-1",
task_id: "task-1",
status: "running",
summary: "Background task still running",
usage: {
total_tokens: 777,
tool_uses: 1,
duration_ms: 50,
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 777,
},
});
});
test("message_start stream events emit usage_updated with per-request usage", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 150,
},
});
});
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 175,
},
});
});
test("task_progress usage takes priority over derived result usage", async () => {
const session = await createSessionForTest();
@@ -508,7 +626,7 @@ describe("ClaudeAgentSession context window usage", () => {
});
});
test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => {
test("contextWindowUsedTokens persists across turns from last task_progress", async () => {
const queryFactory = createQueryFactoryForTurns([
[
{
@@ -600,20 +718,23 @@ describe("ClaudeAgentSession context window usage", () => {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 999,
});
// Turn 2 has no task_progress, so contextWindowUsedTokens retains the
// last known value from turn 1 rather than deriving from accumulated
// result.usage (which would be incorrect — those are session-level totals).
expect(secondTurn.usage).toEqual({
inputTokens: 11,
cachedInputTokens: 6,
outputTokens: 8,
totalCostUsd: 0.1,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 28,
contextWindowUsedTokens: 999,
});
} finally {
await session.close();
}
});
test("convertUsage derives used tokens even when modelUsage is missing", async () => {
test("convertUsage derives used tokens from result usage as fallback when task_progress is missing", async () => {
const session = await createSessionForTest();
const usage = session.convertUsage({
@@ -635,4 +756,134 @@ describe("ClaudeAgentSession context window usage", () => {
contextWindowUsedTokens: 22,
});
});
test("convertUsage uses per-request stream usage when no task_progress is available", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 175,
});
});
test("per-request stream usage is not cumulative across API calls in a turn", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
const secondStartEvents = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 40,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 10,
},
},
},
session_id: "session-1",
} as any);
expect(secondStartEvents).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 55,
},
});
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 7,
},
},
session_id: "session-1",
} as any);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 62,
});
});
});
@@ -1224,16 +1224,67 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
function readUsageTotalTokens(
usage: unknown,
): number | undefined {
const totalTokens = message.usage?.total_tokens;
if (!usage || typeof usage !== "object") {
return undefined;
}
const totalTokens = (usage as { total_tokens?: unknown }).total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
return totalTokens;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readUsageFromTaskNotification(message: { usage?: unknown }): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = (event.message as { usage?: unknown } | undefined)?.usage;
if (!messageUsage || typeof messageUsage !== "object") {
return undefined;
}
const usage = messageUsage as {
input_tokens?: unknown;
cache_creation_input_tokens?: unknown;
cache_read_input_tokens?: unknown;
};
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: undefined;
const cacheCreationInputTokens =
typeof usage.cache_creation_input_tokens === "number" &&
Number.isFinite(usage.cache_creation_input_tokens)
? usage.cache_creation_input_tokens
: 0;
const cacheReadInputTokens =
typeof usage.cache_read_input_tokens === "number" &&
Number.isFinite(usage.cache_read_input_tokens)
? usage.cache_read_input_tokens
: 0;
if (typeof inputTokens !== "number" || inputTokens < 0) {
return undefined;
}
return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
const outputTokens = (event.usage as { output_tokens?: unknown } | undefined)?.output_tokens;
if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) {
return undefined;
}
return outputTokens;
}
class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1277,6 +1328,9 @@ class ClaudeAgentSession implements AgentSession {
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = [];
private recentStderr = "";
private closed = false;
@@ -1459,7 +1513,6 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt);
this.lastContextWindowUsedTokens = undefined;
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
@@ -2303,7 +2356,6 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
}
@@ -2319,12 +2371,10 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn terminal");
}
}
@@ -2337,7 +2387,6 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.lastContextWindowUsedTokens = undefined;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -2348,7 +2397,6 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn completed");
}
@@ -2677,10 +2725,18 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
if (typeof this.lastContextWindowUsedTokens === "number") {
events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens));
}
}
break;
case "user": {
@@ -2748,6 +2804,10 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event": {
const usageUpdatedEvent = this.trackStreamEventUsage(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
@@ -2916,11 +2976,26 @@ class ClaudeAgentSession implements AgentSession {
modelUsage ?? message.modelUsage,
);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (
typeof this.lastStreamRequestInputTokens === "number" &&
typeof this.lastStreamRequestOutputTokens === "number"
) {
usage.contextWindowUsedTokens =
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens;
} else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but
// for the first turn they equal the per-call values so the estimate is
// reasonable. Once a task_progress arrives it takes over permanently.
const usageWithCacheCreation = message.usage as typeof message.usage & {
cache_creation_input_tokens?: number;
};
@@ -2936,6 +3011,54 @@ class ClaudeAgentSession implements AgentSession {
return usage;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
private trackStreamEventUsage(event: unknown): AgentStreamEvent | null {
if (!event || typeof event !== "object") {
return null;
}
const streamEvent = event as Record<string, unknown>;
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.lastStreamRequestInputTokens = inputTokens;
this.lastStreamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.lastStreamRequestOutputTokens = outputTokens;
} else {
return null;
}
if (
typeof this.lastStreamRequestInputTokens !== "number" ||
typeof this.lastStreamRequestOutputTokens !== "number"
) {
return null;
}
return this.createUsageUpdatedEvent(
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens,
);
}
private handlePermissionRequest: CanUseTool = async (
toolName,
input,
@@ -577,6 +577,52 @@ describe("Codex app-server provider", () => {
});
});
test("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
(session as any).handleNotification("thread/tokenUsage/updated", {
tokenUsage: {
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
},
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
});
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
@@ -3506,6 +3506,13 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "token_usage_updated") {
this.latestUsage = toAgentUsage(parsed.tokenUsage);
if (this.latestUsage) {
this.notifySubscribers({
type: "usage_updated",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
return;
}
@@ -539,6 +539,26 @@ describe("codex tool-call mapper", () => {
});
});
it("normalizes codex paseo_voice.speak mcp calls and extracts spoken text", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
id: "codex-speak-thread-2",
status: "completed",
server: "paseo_voice",
tool: "speak",
arguments: { text: "Voice response from Codex via paseo_voice." },
result: { ok: true },
});
expect(item).toBeTruthy();
expect(item?.name).toBe("speak");
expect(item?.detail).toEqual({
type: "unknown",
input: "Voice response from Codex via paseo_voice.",
output: null,
});
});
it("normalizes codex paseo speak rollout names and extracts spoken text", () => {
const item = expectMapped(
mapCodexRolloutToolCall({
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { extractCodexShellOutput, truncateDiffText } from "../tool-call-mapper-utils.js";
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
import { isSpeakToolName } from "../../tool-name-normalization.js";
type CodexMapperOptions = { cwd?: string | null };
@@ -67,7 +68,7 @@ const CodexEditToolNameSchema = z.union([
z.literal("apply_diff"),
]);
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
const CodexSpeakToolNameSchema = z.literal("paseo.speak");
const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim()));
const CodexToolKindSchema = z.enum([
"shell",
@@ -450,16 +450,16 @@ function mergeOpenCodeStepFinishUsage(
const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) {
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
usage.inputTokens = inputTokens;
}
if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
usage.cachedInputTokens = cacheReadTokens;
}
if (outputTokens !== undefined) {
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
usage.outputTokens = outputTokens;
}
if (totalTokens > 0) {
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
usage.contextWindowUsedTokens = totalTokens;
}
if (cost !== undefined) {
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
@@ -1144,6 +1144,13 @@ export function translateOpenCodeEvent(
}
} else if (partType === "step-finish") {
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
events.push({
type: "usage_updated",
provider: "opencode",
usage: { ...state.accumulatedUsage },
});
}
}
break;
}
@@ -2041,6 +2048,17 @@ class OpenCodeAgentSession implements AgentSession {
if (translatedEvent.type === "permission_requested") {
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
}
if (translatedEvent.type === "turn_completed") {
if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) {
translatedEvent.usage = this.accumulatedUsage;
}
const contextWindowMaxTokens =
this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens }
: {};
}
}
return translated;
@@ -255,6 +255,61 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
const events = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "step-finish-1",
sessionID: "session-1",
messageID: "message-usage-1",
type: "step-finish",
reason: "stop",
cost: 0.25,
tokens: {
total: 999_999,
input: 30_000,
output: 12_000,
reasoning: 10_000,
cache: {
read: 2_000,
write: 1_000,
},
},
},
},
},
state,
);
expect(events).toEqual([
{
type: "usage_updated",
provider: "opencode",
usage: {
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
},
},
]);
expect(state.accumulatedUsage).toEqual({
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
});
});
it("emits reasoning from message.part.delta events", () => {
const state = createState();
+12 -4
View File
@@ -120,6 +120,7 @@ import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
wrapSpokenInput,
} from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
@@ -2712,6 +2713,7 @@ export class Session {
messageId?: string,
images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean },
): Promise<void> {
this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
@@ -2739,7 +2741,8 @@ export class Session {
);
}
const prompt = this.buildAgentPrompt(text, images);
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images);
this.startAgentStream(agentId, prompt, runOptions);
}
@@ -7095,9 +7098,14 @@ export class Session {
return;
}
// Route voice utterances through the same send path as regular text input:
// interrupt-if-running, record message, then start a new stream.
await this.handleSendAgentMessage(agentId, result.text);
await this.handleSendAgentMessage(
agentId,
result.text,
undefined,
undefined,
undefined,
{ spokenInput: true },
);
await this.flushPendingAudioSegments("transcription complete");
}
@@ -308,7 +308,8 @@ describe("workspace git watch targets", () => {
workspaceDirectory: "/tmp/repo",
};
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -554,7 +555,8 @@ describe("workspace git watch targets", () => {
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -8,10 +8,17 @@ import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
const DEFAULT_SAMPLE_RATE = 16000;
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
const DEFAULT_SILERO_THRESHOLD = 0.5;
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
const DEFAULT_MIN_SPEECH_DURATION = 0.2;
const DEFAULT_WINDOW_SIZE = 512;
// Silero internal durations — kept low so isDetected() tracks actual sound.
// Our own state machine handles speech confirmation and end-of-speech detection.
const SILERO_MIN_SILENCE_DURATION = 0.2;
const SILERO_MIN_SPEECH_DURATION = 0.1;
// Our boundary detection thresholds (in milliseconds).
const DEFAULT_CONFIRM_MS = 800;
const DEFAULT_SILENCE_MS = 1000;
type SherpaVadHandle = {
acceptWaveform(samples: Float32Array): void;
isDetected(): boolean;
@@ -42,20 +49,30 @@ export interface SherpaSileroVadSessionConfig {
modelPath?: string;
sampleRate?: number;
threshold?: number;
minSilenceDuration?: number;
minSpeechDuration?: number;
windowSize?: number;
bufferSizeInSeconds?: number;
confirmMs?: number;
silenceMs?: number;
}
type VadPhase =
| { state: "idle" }
| { state: "confirming"; startedAt: number }
| { state: "speaking" }
| { state: "ending"; startedAt: number };
export class SherpaSileroVadSession extends EventEmitter implements TurnDetectionSession {
public readonly requiredSampleRate: number;
private readonly vad: SherpaVadHandle;
private readonly inputBuffer: SherpaCircularBufferHandle;
private readonly windowSize: number;
private readonly msPerWindow: number;
private readonly confirmMs: number;
private readonly silenceMs: number;
private connected = false;
private inSpeech = false;
private phase: VadPhase = { state: "idle" };
private windowTimestamp = 0;
private readonly logger;
constructor(params: {
@@ -67,16 +84,35 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
const config = params.config ?? {};
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
this.confirmMs = config.confirmMs ?? DEFAULT_CONFIRM_MS;
this.silenceMs = config.silenceMs ?? DEFAULT_SILENCE_MS;
const threshold = config.threshold ?? DEFAULT_SILERO_THRESHOLD;
this.logger.debug(
{
threshold,
sileroMinSilenceDuration: SILERO_MIN_SILENCE_DURATION,
sileroMinSpeechDuration: SILERO_MIN_SPEECH_DURATION,
confirmMs: this.confirmMs,
silenceMs: this.silenceMs,
windowSize: this.windowSize,
msPerWindow: this.msPerWindow,
sampleRate: this.requiredSampleRate,
},
"[VAD] Initializing Silero VAD session",
);
const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule;
this.vad = new sherpa.Vad(
{
sileroVad: {
model: config.modelPath ?? resolveBundledSileroVadModelPath(),
threshold: config.threshold ?? DEFAULT_SILERO_THRESHOLD,
minSilenceDuration: config.minSilenceDuration ?? DEFAULT_MIN_SILENCE_DURATION,
minSpeechDuration: config.minSpeechDuration ?? DEFAULT_MIN_SPEECH_DURATION,
windowSize: config.windowSize ?? DEFAULT_WINDOW_SIZE,
threshold,
minSilenceDuration: SILERO_MIN_SILENCE_DURATION,
minSpeechDuration: SILERO_MIN_SPEECH_DURATION,
windowSize: this.windowSize,
},
sampleRate: this.requiredSampleRate,
numThreads: 1,
@@ -106,11 +142,14 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
try {
const samples = pcm16leToFloat32(pcm16le, 1);
this.inputBuffer.push(samples);
let windowsProcessed = 0;
while (this.inputBuffer.size() > this.windowSize) {
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
this.inputBuffer.pop(this.windowSize);
this.vad.acceptWaveform(window);
this.syncDetectionState();
this.windowTimestamp += this.msPerWindow;
windowsProcessed++;
this.stepStateMachine();
}
} catch (error) {
this.emit("error", error instanceof Error ? error : new Error(String(error)));
@@ -123,41 +162,111 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
}
try {
this.logger.debug(
{ phase: this.phase.state },
"[VAD] Flushing remaining audio",
);
this.vad.flush();
this.syncDetectionState();
if (this.inSpeech) {
this.inSpeech = false;
this.stepStateMachine();
if (this.phase.state === "speaking" || this.phase.state === "ending") {
this.logger.debug("[VAD] Forcing speech_stopped after flush");
this.phase = { state: "idle" };
this.emit("speech_stopped");
} else if (this.phase.state === "confirming") {
this.logger.debug("[VAD] Discarding unconfirmed speech on flush");
this.phase = { state: "idle" };
}
} catch (error) {
this.emit("error", error instanceof Error ? error : new Error(String(error)));
}
}
close(): void {
reset(): void {
try {
this.vad.reset();
this.inputBuffer.reset();
} catch {
// ignore native cleanup failures
} finally {
this.connected = false;
this.inSpeech = false;
this.phase = { state: "idle" };
}
}
private syncDetectionState(): void {
const detected = this.vad.isDetected();
if (detected && !this.inSpeech) {
this.inSpeech = true;
this.emit("speech_started");
return;
}
close(): void {
this.reset();
this.connected = false;
this.windowTimestamp = 0;
}
if (!detected && this.inSpeech && !this.vad.isEmpty()) {
this.logger.debug("Silero VAD marked end of speech");
this.inSpeech = false;
this.emit("speech_stopped");
private stepStateMachine(): void {
const detected = this.vad.isDetected();
const now = this.windowTimestamp;
switch (this.phase.state) {
case "idle": {
if (detected) {
this.logger.debug(
{ now },
"[VAD] idle → confirming (detection started)",
);
this.phase = { state: "confirming", startedAt: now };
}
break;
}
case "confirming": {
if (!detected) {
const elapsed = now - this.phase.startedAt;
this.logger.debug(
{ elapsed, confirmMs: this.confirmMs },
"[VAD] confirming → idle (detection dropped before confirmation)",
);
this.phase = { state: "idle" };
break;
}
const elapsed = now - this.phase.startedAt;
if (elapsed >= this.confirmMs) {
this.logger.debug(
{ elapsed, confirmMs: this.confirmMs },
"[VAD] confirming → speaking (speech confirmed)",
);
this.phase = { state: "speaking" };
this.emit("speech_started");
}
break;
}
case "speaking": {
if (!detected) {
this.logger.debug(
{ now },
"[VAD] speaking → ending (silence started)",
);
this.phase = { state: "ending", startedAt: now };
}
break;
}
case "ending": {
if (detected) {
this.logger.debug(
{ elapsed: now - this.phase.startedAt },
"[VAD] ending → speaking (speech resumed)",
);
this.phase = { state: "speaking" };
break;
}
const elapsed = now - this.phase.startedAt;
if (elapsed >= this.silenceMs) {
this.logger.debug(
{ elapsed, silenceMs: this.silenceMs },
"[VAD] ending → idle (speech stopped)",
);
this.phase = { state: "idle" };
this.emit("speech_stopped");
}
break;
}
}
}
}
@@ -10,6 +10,7 @@ export type TurnDetectionSession = {
connect(): Promise<void>;
appendPcm16(pcm16le: Buffer): void;
flush(): void;
reset(): void;
close(): void;
on(event: "speech_started", handler: () => void): unknown;
@@ -53,6 +53,10 @@ export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled
.join("\n\n");
}
export function wrapSpokenInput(text: string): string {
return `<spoken-input>\n${text}\n</spoken-input>`;
}
export function buildVoiceAgentMcpServerConfig(params: {
command: string;
baseArgs: string[];
@@ -19,6 +19,7 @@ class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSess
}
flush(): void {}
reset(): void {}
close(): void {}
}
@@ -9,7 +9,7 @@ import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js"
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
const DEFAULT_PREFIX_DURATION_MS = 400;
const DEFAULT_PREFIX_DURATION_MS = 1000;
type VoiceInputState =
| { status: "idle" }
@@ -134,6 +134,8 @@ export function createVoiceTurnController(params: {
utteranceChunks = [];
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
detector.reset();
await params.callbacks.onSpeechStopped();
params.logger.info(