From c5be399fddb7c2df05f10cc028fa6c9c1a33fa56 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Thu, 11 Jun 2026 11:50:39 +0000 Subject: [PATCH] sync: update from private repo (0dd1a8e) --- src/engine/reflection/llm-client.test.ts | 10 ++++ src/engine/reflection/llm-client.ts | 14 ++++- src/engine/tools/image.ts | 17 ++++++ src/engine/tools/image.vision-usage.test.ts | 65 +++++++++++++++++++++ src/llm/openai-compat.ts | 16 ++++- src/llm/usage-recorder.ts | 4 ++ src/llm/usage-recording.test.ts | 55 +++++++++++++++++ ui/src/App.tsx | 9 ++- ui/src/components/chat/ChatPane.tsx | 46 +++++++++++---- ui/src/components/detail/DetailHeader.tsx | 17 +++++- ui/src/components/detail/DetailPanel.tsx | 47 ++------------- ui/src/components/detail/detailTabs.ts | 51 ++++++++++++++++ ui/src/i18n/locales/en/chat.json | 2 - ui/src/i18n/locales/ja/chat.json | 2 - ui/src/index.css | 47 +++++++++++++++ 15 files changed, 337 insertions(+), 65 deletions(-) create mode 100644 src/engine/tools/image.vision-usage.test.ts create mode 100644 ui/src/components/detail/detailTabs.ts diff --git a/src/engine/reflection/llm-client.test.ts b/src/engine/reflection/llm-client.test.ts index e15dbc4..7075bc5 100644 --- a/src/engine/reflection/llm-client.test.ts +++ b/src/engine/reflection/llm-client.test.ts @@ -88,6 +88,16 @@ describe('callReflectionLlm', () => { expect(result.durationMs).toBeGreaterThanOrEqual(0); }); + it('reconstructs raw with the resolved tool_call + usage (#500)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream())); + const result = await callReflectionLlm(cfg, 'system', 'user'); + const raw = result.raw as { usage?: unknown; choices?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } }> }; + expect(raw.usage).toEqual({ prompt_tokens: 42, completion_tokens: 17 }); + const tc = raw.choices?.[0]?.message?.tool_calls?.[0]?.function; + expect(tc?.name).toBe('submit_reflection'); + expect(JSON.parse(tc!.arguments!)).toMatchObject({ reasoning: 'x' }); + }); + it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}')) diff --git a/src/engine/reflection/llm-client.ts b/src/engine/reflection/llm-client.ts index aa97ef4..90fe694 100644 --- a/src/engine/reflection/llm-client.ts +++ b/src/engine/reflection/llm-client.ts @@ -134,6 +134,7 @@ async function callOnce( let usage: { prompt_tokens: number; completion_tokens: number } | undefined; let errorMsg: string | null = null; let errorGatewayType: string | undefined; + let backendId: string | undefined; for await (const event of client.chat( messages, @@ -148,6 +149,8 @@ async function callOnce( } } else if (event.type === 'done') { usage = event.usage; + } else if (event.type === 'backend') { + backendId = event.backendId; } else if (event.type === 'error') { errorMsg = event.error; errorGatewayType = event.gatewayErrorType; @@ -173,6 +176,15 @@ async function callOnce( tokensIn: usage?.prompt_tokens ?? 0, tokensOut: usage?.completion_tokens ?? 0, durationMs: Date.now() - start, - raw: { usage }, + // Reconstruct the OpenAI response shape so `raw` keeps debugging fidelity + // after the move to the streaming client (issue #500): the resolved + // tool_call, usage, and (proxy) backend id rather than just `{ usage }`. + raw: { + usage, + backendId, + choices: [ + { message: { tool_calls: [{ function: { name: 'submit_reflection', arguments: JSON.stringify(parsed) } }] } }, + ], + }, }; } diff --git a/src/engine/tools/image.ts b/src/engine/tools/image.ts index 5126235..de441c7 100644 --- a/src/engine/tools/image.ts +++ b/src/engine/tools/image.ts @@ -5,6 +5,7 @@ import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { resolveAndGuard, resolveOutputPathWithin } from './core.js'; import { logger } from '../../logger.js'; +import { recordLlmUsage } from '../../llm/usage-recorder.js'; // --- Supported image extensions --- @@ -310,7 +311,9 @@ export async function callVisionModel( } const json = (await response.json()) as { + model?: string; choices?: Array<{ message?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; }; const content = json.choices?.[0]?.message?.content; @@ -318,6 +321,20 @@ export async function callVisionModel( return { output: 'Vision API returned no content', isError: true }; } + // This vision call is a raw fetch outside OpenAICompatClient, so it would + // otherwise miss the per-user usage ledger. Record it directly (issue + // #499). Vision uses its own endpoint directly (never the gateway). + let route = 'unknown'; + try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ } + recordLlmUsage({ + userId: ctx.userId ?? 'local', + source: 'direct', + model: json.model || visionModel, + route, + tokensIn: json.usage?.prompt_tokens ?? 0, + tokensOut: json.usage?.completion_tokens ?? 0, + }); + return { output: content, isError: false }; } catch (e) { if ((e as Error).name === 'AbortError') { diff --git a/src/engine/tools/image.vision-usage.test.ts b/src/engine/tools/image.vision-usage.test.ts new file mode 100644 index 0000000..66a55b8 --- /dev/null +++ b/src/engine/tools/image.vision-usage.test.ts @@ -0,0 +1,65 @@ +/** + * ReadImage vision call records to the per-user usage ledger (issue #499). + * The vision call is a raw fetch outside OpenAICompatClient, so it records + * directly via recordLlmUsage. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { callVisionModel } from './image.js'; +import type { ToolContext } from './core.js'; +import { setLlmUsageRecorder, type LlmUsageEvent } from '../../llm/usage-recorder.js'; + +function ctx(): ToolContext { + return { + workspacePath: '/tmp', + userId: 'u1', + toolsConfig: { visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl' }, + } as unknown as ToolContext; +} + +afterEach(() => { + setLlmUsageRecorder(null); + vi.unstubAllGlobals(); +}); + +describe('callVisionModel usage recording', () => { + it('records a direct usage event with the vision host as route', async () => { + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + model: 'qwen2-vl', + choices: [{ message: { content: 'a cat' } }], + usage: { prompt_tokens: 250, completion_tokens: 12 }, + }), + })); + + const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx()); + expect(res.isError).toBe(false); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + userId: 'u1', source: 'direct', model: 'qwen2-vl', route: 'vision-host:11434', + tokensIn: 250, tokensOut: 12, + }); + }); + + it('records zero tokens when the vision response omits usage', async () => { + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'x' } }] }), + })); + await callVisionModel('data:image/png;base64,xxx', '', ctx()); + expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, model: 'qwen2-vl' }); + }); + + it('does not record on a vision API error', async () => { + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' })); + const res = await callVisionModel('data:image/png;base64,xxx', '', ctx()); + expect(res.isError).toBe(true); + expect(events).toHaveLength(0); + }); +}); diff --git a/src/llm/openai-compat.ts b/src/llm/openai-compat.ts index 3c3222d..73460d7 100644 --- a/src/llm/openai-compat.ts +++ b/src/llm/openai-compat.ts @@ -452,6 +452,10 @@ export class OpenAICompatClient { // per attempt; only the attempt that reaches `done` records. let observedModel = ''; let observedBackendId = ''; + // Whether we saw at least one real (non-error) SSE chunk. An EOF + // that closes the stream without any chunk and without [DONE] is an + // abnormal completion we must NOT record as a request (issue #498). + let sawChunk = false; try { response = await fetch(`${this.baseUrl}/chat/completions`, { @@ -616,10 +620,15 @@ export class OpenAICompatClient { }; return; } + // Unknown error.type: keep parsing (it may be followed by real + // content). It is NOT a real chunk, so it doesn't flip + // `sawChunk` — an error-only stream that then EOFs without + // [DONE] stays unrecorded (issue #498). } // usage (stream_options で末尾チャンクに付く) if (chunk['usage'] != null) { + sawChunk = true; // a real completion payload const u = chunk['usage'] as Record; usage = { prompt_tokens: (u['prompt_tokens'] as number) ?? 0, @@ -641,6 +650,7 @@ export class OpenAICompatClient { const choices = chunk['choices'] as Array> | undefined; if (!choices || choices.length === 0) continue; + sawChunk = true; // a real content/finish chunk const choice = choices[0] as Record; const delta = choice['delta'] as Record | undefined; @@ -737,9 +747,11 @@ export class OpenAICompatClient { reader.releaseLock(); } - // [DONE] なしにストリームが終了した場合 + // [DONE] なしにストリームが終了した場合。チャンクを1つも受け取らずに + // EOF した「不明完了」は requests に数えない (issue #498)。明示的な + // [DONE] 経路は従来どおり常に記録する。 yield* drainToolCalls(toolCallAccumulators); - this.finalizeDone(usage, observedModel, observedBackendId, context); + if (sawChunk) this.finalizeDone(usage, observedModel, observedBackendId, context); yield { type: 'done', usage }; return; } diff --git a/src/llm/usage-recorder.ts b/src/llm/usage-recorder.ts index 1c9e095..80105e8 100644 --- a/src/llm/usage-recorder.ts +++ b/src/llm/usage-recorder.ts @@ -9,6 +9,10 @@ import { logger } from '../logger.js'; * construction. This is the single chokepoint the design relies on to * avoid the propagation leaks this codebase has repeatedly hit. * + * Exception: the ReadImage vision tool (engine/tools/image.ts) issues a + * raw, non-streaming fetch to its own vision endpoint rather than going + * through OpenAICompatClient, so it calls recordLlmUsage() directly. + * * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md */ export interface LlmUsageEvent { diff --git a/src/llm/usage-recording.test.ts b/src/llm/usage-recording.test.ts index 7b8b27c..e1f636f 100644 --- a/src/llm/usage-recording.test.ts +++ b/src/llm/usage-recording.test.ts @@ -123,3 +123,58 @@ describe('LLM usage recording', () => { await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined(); }); }); + +// --- issue #498: don't record unknown completions --- + +/** Raw SSE response with full control over whether [DONE] is sent. */ +function rawSse(chunks: unknown[], withDone: boolean): Response { + const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`); + if (withDone) lines.push('data: [DONE]\n\n'); + const encoder = new TextEncoder(); + let i = 0; + return { + ok: true, status: 200, + headers: { get: () => null }, + body: { getReader: () => ({ + read: async () => (i < lines.length ? { done: false, value: encoder.encode(lines[i++]) } : { done: true, value: undefined }), + releaseLock: () => {}, + }) }, + } as unknown as Response; +} + +async function collect(client: OpenAICompatClient, ctx?: { userId?: string }) { + const out: string[] = []; + for await (const e of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) out.push(e.type); + return out; +} + +describe('LLM usage recording — unknown completions (#498)', () => { + it('does NOT record an EOF with no chunks and no [DONE]', async () => { + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([], false))); + const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' }); + expect(events).toHaveLength(0); + expect(types[types.length - 1]).toBe('done'); // still terminates gracefully + }); + + it('DOES record an EOF (no [DONE]) once a real chunk arrived', async () => { + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([textChunk('m'), usageChunk(3, 2)], false))); + await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ tokensIn: 3, tokensOut: 2 }); + }); + + it('does NOT record an error-only stream that EOFs without [DONE]', async () => { + // An unknown error payload alone is not a real chunk; if the stream then + // closes without [DONE], it must not be counted as a request. + const events: LlmUsageEvent[] = []; + setLlmUsageRecorder((e) => events.push(e)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([{ error: { type: 'mystery', message: 'kaboom' } }], false))); + const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' }); + expect(events).toHaveLength(0); + expect(types[types.length - 1]).toBe('done'); // unknown error falls through, EOF → done + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ac40ba8..3f4af20 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ import { TopBar } from './components/layout/TopBar'; import { NavDrawer } from './components/layout/NavDrawer'; import { useEdgeSwipe } from './hooks/useEdgeSwipe'; import { visibleNavItemsFor } from './components/layout/TopBar'; +import { useVisibleDetailTabs } from './components/detail/detailTabs'; import { ResizeHandle } from './components/layout/ResizeHandle'; import { TaskListPanel } from './components/list/TaskListPanel'; import { ChatPane } from './components/chat/ChatPane'; @@ -168,6 +169,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable // nav drawer / edge-swipe stay in sync with whether the hamburger is shown. const [compactMode, setCompactMode] = useState(false); const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); + // Detail tabs (with live Browser/SSH gating) for the tablet chat header. + const visibleDetailTabs = useVisibleDetailTabs(localTaskId); + const openDetailTab = useCallback((id: string) => { + setUrlState(prev => ({ ...prev, detailTab: id as typeof detailTab })); + setTabletDetailOpen(true); + }, [setUrlState]); const openNavDrawer = () => { setTabletDetailOpen(false); @@ -581,7 +588,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{chatReady ? ( - setTabletDetailOpen(true)} /> + ) : panelOpen ? ( ) : ( diff --git a/ui/src/components/chat/ChatPane.tsx b/ui/src/components/chat/ChatPane.tsx index 3da99b9..4c78d5b 100644 --- a/ui/src/components/chat/ChatPane.tsx +++ b/ui/src/components/chat/ChatPane.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { LocalTask, LocalTaskComment } from '../../api'; import { ChatMessage } from './ChatMessage'; @@ -27,11 +27,16 @@ interface ChatPaneProps { comments: LocalTaskComment[]; onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise; onCancel?: () => Promise; - onOpenDetail?: () => void; + /** Tablet: the detail tabs. Tapping one opens the detail drawer to that tab, + * replacing the single "詳細" button so you jump straight where you want. */ + detailTabs?: Array<{ id: string; labelKey: string }>; + activeDetailTab?: string; + onSelectDetailTab?: (id: string) => void; } -export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: ChatPaneProps) { +export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) { const { t } = useTranslation('chat'); + const { t: dt } = useTranslation('detail'); const [draft, setDraft] = useState(''); const [attachments, setAttachments] = useState>([]); const [submitting, setSubmitting] = useState(false); @@ -259,17 +264,34 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
)} - {onOpenDetail && ( - - )} + + {/* Tablet: detail tabs as direct buttons (instead of one "詳細" button) + so a tap opens the drawer straight to the chosen tab. */} + {detailTabs && detailTabs.length > 0 && onSelectDetailTab && ( +
+ {detailTabs.map(tab => { + const active = tab.id === activeDetailTab; + const live = tab.id === 'browser' || tab.id === 'ssh'; + return ( + + ); + })} +
+ )} {/* Messages */} diff --git a/ui/src/components/detail/DetailHeader.tsx b/ui/src/components/detail/DetailHeader.tsx index 571cc6d..f29984d 100644 --- a/ui/src/components/detail/DetailHeader.tsx +++ b/ui/src/components/detail/DetailHeader.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { DetailTabId } from '../../lib/urlState'; @@ -231,6 +231,10 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe {tabs.map(tab => { const active = activeTab === tab.id; const pending = active && tabTransitionPending; + // Browser/SSH tabs only appear while their session is LIVE (gated in + // DetailPanel), so any browser/ssh tab here is running — give it the + // flowing-light border to signal "this is live right now". + const live = tab.id === 'browser' || tab.id === 'ssh'; return (