From 14f5806b85aad5c6ec1beef1986b5593138f30c7 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:01 +0900 Subject: [PATCH 1/5] fix(gjc): let the runtime choose the stored account, as the CLI does With several stored rows for a provider the adapter pinned the lowest row id. That bypassed gjc accounts pin, routing exclusions and usage-limit rotation, and sent every Anthropic turn to an account whose organization refuses OAuth (403) while the CLI answered from another. Only an explicit credentialId installs a selector now; the run reports the row the runtime settled on. --- server/gjc-bun-sdk-adapter.ts | 20 +++++++++++---- server/gjc-sdk-contract.bun.test.ts | 38 +++++++++++++++++++---------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index d0b7d3e3..761a45fd 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -660,11 +660,16 @@ async function credentialFor( if (await authStorage.peekApiKey(model.provider) === undefined) throw new GjcModelResolutionError(); return { dispose() {} }; } - // Deterministic selection: explicit credentialId wins; otherwise the lowest - // stored row id. Installing a selector also blocks the env-var fallback. - const row = credential.credentialId !== undefined - ? rows.find((candidate) => candidate.id === credential.credentialId) - : rows[0]; + // Without an explicit row the runtime selects the account itself, exactly + // as the CLI does: it honours `gjc accounts pin`, skips accounts its + // routing has ruled out and rotates to another account on a usage limit. + // A selector would disable all three - pinning the lowest row id sent + // every run to an account the CLI never picks. The row it chose is + // reported after the prompt (`sessionCredential`). + if (credential.credentialId === undefined) return { dispose() {} }; + // An explicit credentialId pins that row, which also blocks the env-var + // fallback and usage-limit rotation. + const row = rows.find((candidate) => candidate.id === credential.credentialId); if (!row) throw new Error(FAILURE); return { credentialSelector: { @@ -1576,6 +1581,11 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { await Promise.race([titleTask, new Promise((resolve) => { grace = setTimeout(resolve, graceMs); })]); clearTimeout(grace); } + if (!resolvedCredential.credential) { + // The runtime chose the account on the first request; report that row. + const rowId = this.authStorage.getSessionCredentialRowId(model.provider, result.session.credentialSessionId); + if (rowId !== undefined) writer.setCredential?.({ kind: 'stored', providerId: model.provider, credentialId: rowId }); + } await delegation?.dispose(); if (promptError !== undefined) throw promptError; } finally { diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index ce925a21..951ec664 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -297,6 +297,9 @@ async function fixture( resolvableProviders: new Set(), exportSnapshot() { return { credentials: this.credentials }; }, async peekApiKey(provider: string) { return this.resolvableProviders.has(provider) ? 'peeked-key' : undefined; }, + /** The stored row the runtime's own account selection settled on, per provider. */ + sessionRows: new Map(), + getSessionCredentialRowId(provider: string) { return this.sessionRows.get(provider); }, async login(provider: string, callbacks: OAuthCallbacks) { await oauthLogin?.(provider, callbacks); this.credentials.push({ id: this.credentials.length + 1, provider }); @@ -913,12 +916,15 @@ test('model catalog honors registry availability and credential changes independ } }); -test('provider-qualified catalog choices and the default select the matching stored credential', async (t) => { +test('provider-qualified catalog choices route to the matching provider; only an explicit row is pinned', async (t) => { + // Without an explicit row the runtime picks the account (CLI parity: `gjc + // accounts pin`, routing exclusions, usage-limit rotation), and the run + // reports the row it settled on. `selected` is that runtime choice. const cases = [ - { name: 'explicit proxy', modelId: 'cliproxy/gpt-6-astra', credential: { kind: 'stored' }, provider: 'cliproxy', credentialId: 1 }, - { name: 'explicit codex', modelId: 'openai-codex/gpt-6-astra', credential: { kind: 'stored' }, provider: 'openai-codex', credentialId: 4 }, - { name: 'pinned codex credential', modelId: 'openai-codex/gpt-6-astra', credential: { kind: 'stored', providerId: 'openai-codex', credentialId: 9 }, provider: 'openai-codex', credentialId: 9 }, - { name: 'configured default', modelId: 'default', credential: { kind: 'stored' }, provider: 'openai-codex', credentialId: 4 }, + { name: 'explicit proxy', modelId: 'cliproxy/gpt-6-astra', credential: { kind: 'stored' }, provider: 'cliproxy', credentialId: 1, pinned: false }, + { name: 'explicit codex', modelId: 'openai-codex/gpt-6-astra', credential: { kind: 'stored' }, provider: 'openai-codex', credentialId: 9, pinned: false }, + { name: 'pinned codex credential', modelId: 'openai-codex/gpt-6-astra', credential: { kind: 'stored', providerId: 'openai-codex', credentialId: 4 }, provider: 'openai-codex', credentialId: 4, pinned: true }, + { name: 'configured default', modelId: 'default', credential: { kind: 'stored' }, provider: 'openai-codex', credentialId: 9, pinned: false }, ]; for (const scenario of cases) { await t.test(scenario.name, async () => { @@ -928,6 +934,8 @@ test('provider-qualified catalog choices and the default select the matching sto ]); try { f.authStorage.credentials.push({ id: 9, provider: 'openai-codex' }, { id: 1, provider: 'cliproxy' }, { id: 4, provider: 'openai-codex' }); + // The runtime's choice is deliberately not the lowest row id. + f.authStorage.sessionRows.set('openai-codex', 9).set('cliproxy', 1); const run = f.host.handle(request('session.start', 'variant-credential', { message: 'hello', options: { ...f.options, modelId: scenario.modelId, credential: scenario.credential, effort: 'xhigh' }, @@ -936,11 +944,11 @@ test('provider-qualified catalog choices and the default select the matching sto session.complete(); await run; assert.deepEqual(f.factoryOptions[0]!.model, { id: 'gpt-6-astra', provider: scenario.provider }); - assert.deepEqual(f.factoryOptions[0]!.credentialSelector, { + assert.deepEqual(f.factoryOptions[0]!.credentialSelector, scenario.pinned ? { provider: scenario.provider, selector: { kind: 'id', value: String(scenario.credentialId) }, raw: `id:${scenario.credentialId}`, - }); + } : undefined); const payload = response(f.frames, 'variant-credential').payload as { ok: boolean; result: { credential: unknown } }; assert.equal(payload.ok, true); assert.deepEqual(payload.result.credential, { kind: 'stored', providerId: scenario.provider, credentialId: scenario.credentialId }); @@ -2926,21 +2934,25 @@ test('explicit SDK configuration rejects missing fields, unresolvable credential await f.host.handle(request('session.start', 'invalid-zero-rows', { message: 'x', options: { ...f.options, credential: { kind: 'stored' } } })); assert.equal((response(f.frames, 'invalid-zero-rows').payload as Record).ok, false); assert.equal(f.sessions.length, 0); - // Multiple stored rows resolve deterministically to the lowest row id. + // Multiple stored rows leave the choice to the runtime, as in the CLI: no + // selector is installed (it would disable `gjc accounts pin`, routing + // exclusions and usage-limit rotation), and the run reports the row the + // runtime settled on - here not the lowest id. f.authStorage.credentials = [ { id: 7, provider: 'contract-provider' }, { id: 2, provider: 'contract-provider' }, ]; - const run = f.host.handle(request('session.start', 'stored-deterministic', { message: 'x', options: { ...f.options, credential: { kind: 'stored' } } })); + f.authStorage.sessionRows.set('contract-provider', 7); + const run = f.host.handle(request('session.start', 'stored-runtime-choice', { message: 'x', options: { ...f.options, credential: { kind: 'stored' } } })); const session = await firstSession(f.sessions); session.complete(); await run; - const factoryInput = f.factoryOptions.at(-1) as { credentialSelector?: { selector: { value: string } } }; - assert.equal(factoryInput.credentialSelector?.selector.value, '2'); - assert.deepEqual(((response(f.frames, 'stored-deterministic').payload as Record).result as Record).credential, { + const factoryInput = f.factoryOptions.at(-1) as { credentialSelector?: unknown }; + assert.equal(factoryInput.credentialSelector, undefined); + assert.deepEqual(((response(f.frames, 'stored-runtime-choice').payload as Record).result as Record).credential, { kind: 'stored', providerId: 'contract-provider', - credentialId: 2, + credentialId: 7, }); } finally { await f.close(); } }); From 533e478e8f64f9f5ece234113990d1c2346ed861 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:04 +0900 Subject: [PATCH 2/5] feat(chat): stream reasoning while the model thinks Reasoning reached the browser as one block at thinking_end, so a long reasoning phase looked frozen while the terminal streamed it. thinking_delta previews now fill one live row per session, painted once a frame; the thinking record replaces it, and the end of the turn clears one that never got its record. --- server/gjc-bun-sdk-events.test.ts | 16 ++++++++ server/gjc-bun-sdk-events.ts | 6 +++ server/gjc-worker.ts | 2 +- .../services/session-export.service.ts | 1 + server/shared/types.ts | 2 +- src/components/chat/hooks/useChatMessages.ts | 5 ++- .../useChatRealtimeHandlers.dom.bun.test.tsx | 34 ++++++++++++++++ .../chat/hooks/useChatRealtimeHandlers.ts | 39 ++++++++++++++++++- src/components/chat/view/MessageComponent.tsx | 2 +- src/hooks/useSessionAttentionSync.ts | 2 +- src/stores/useSessionStore.ts | 26 ++++++++++++- 11 files changed, 127 insertions(+), 8 deletions(-) diff --git a/server/gjc-bun-sdk-events.test.ts b/server/gjc-bun-sdk-events.test.ts index 5f34a0fc..de746085 100644 --- a/server/gjc-bun-sdk-events.test.ts +++ b/server/gjc-bun-sdk-events.test.ts @@ -441,3 +441,19 @@ test('the live path emits the same details shape the transcript persists', () => assert.equal(live?.content, history.content); assert.deepEqual(live?.toolUseResult, history.toolUseResult); }); + +test('reasoning streams as thinking_delta previews and still ends in one thinking record', () => { + const update = (assistantMessageEvent: Record) => ({ type: 'message_update', assistantMessageEvent }); + const { messages } = forward([ + update({ type: 'thinking_start' }), + update({ type: 'thinking_delta', delta: 'Weigh ' }), + update({ type: 'reasoning_summary_delta', delta: 'the options' }), + update({ type: 'thinking_delta', delta: '' }), + update({ type: 'thinking_end', content: 'Weigh the options' }), + update({ type: 'text_delta', delta: 'Answer' }), + ]); + + assert.deepEqual(all(messages, 'thinking_delta').map((message) => message.content), ['Weigh ', 'the options']); + assert.deepEqual(all(messages, 'thinking').map((message) => message.content), ['Weigh the options']); + assert.deepEqual(messages.map((message) => message.kind), ['thinking_delta', 'thinking_delta', 'thinking', 'stream_delta']); +}); diff --git a/server/gjc-bun-sdk-events.ts b/server/gjc-bun-sdk-events.ts index 50a36772..aa543eaa 100644 --- a/server/gjc-bun-sdk-events.ts +++ b/server/gjc-bun-sdk-events.ts @@ -208,6 +208,12 @@ export function forwardSdkEvent( const update = object(event.assistantMessageEvent) ? event.assistantMessageEvent : undefined; if (update?.type === 'text_delta' && typeof update.delta === 'string') { writer.send({ kind: 'stream_delta', content: update.delta }); + } else if ((update?.type === 'thinking_delta' || update?.type === 'reasoning_summary_delta') && typeof update.delta === 'string' && update.delta) { + // A long reasoning phase used to reach the browser as one block at its + // end, so a turn looked frozen for as long as the model thought while + // the terminal streamed it. The deltas are a live preview only: the + // `thinking` frame at `thinking_end` stays the record and replaces it. + writer.send({ kind: 'thinking_delta', content: update.delta }); } else if (update?.type === 'thinking_end') { const content = typeof update.content === 'string' ? update.content : ''; if (content) writer.send({ kind: 'thinking', content }); diff --git a/server/gjc-worker.ts b/server/gjc-worker.ts index 3c20e2cb..09f200da 100644 --- a/server/gjc-worker.ts +++ b/server/gjc-worker.ts @@ -576,7 +576,7 @@ export class GjcWorkerHost { return; } let method: Exclude = 'message.completed'; - if (message.kind === 'stream_delta') method = 'message.delta'; + if (message.kind === 'stream_delta' || message.kind === 'thinking_delta') method = 'message.delta'; else if (message.kind === 'tool_use') method = 'tool.started'; else if (message.kind === 'tool_result') method = 'tool.completed'; else if (message.kind === 'permission_request' || message.kind === 'permission_cancelled') method = 'ask.presented'; diff --git a/server/modules/providers/services/session-export.service.ts b/server/modules/providers/services/session-export.service.ts index a48a5ab2..72e24983 100644 --- a/server/modules/providers/services/session-export.service.ts +++ b/server/modules/providers/services/session-export.service.ts @@ -22,6 +22,7 @@ export type SessionExport = { */ const TRANSIENT_KINDS = new Set([ 'stream_delta', + 'thinking_delta', 'stream_end', 'complete', 'status', diff --git a/server/shared/types.ts b/server/shared/types.ts index c85c2d00..3ad94493 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -22,7 +22,7 @@ export interface ProviderCurrentActiveModel { model: string; } export interface ProviderChangeActiveModelInput { model: string; sessionId: string; } export interface ProviderSessionActiveModelChange { changed: boolean; model: string | null; provider: LLMProvider; sessionId: string; supported: boolean; } -type MessageKind = 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end' | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' | 'session_created' | 'session_title' | 'interactive_prompt' | 'task_notification' | 'system_notice' | 'delegation_updated'; +type MessageKind = 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'thinking_delta' | 'stream_delta' | 'stream_end' | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' | 'session_created' | 'session_title' | 'interactive_prompt' | 'task_notification' | 'system_notice' | 'delegation_updated'; interface NormalizedToolResult { content?: string; isError?: boolean; toolUseResult?: unknown; } export interface NormalizedMessage { id: string; sessionId: string; timestamp: string; provider: LLMProvider; kind: MessageKind; diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index d71cd3e7..5be57171 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -166,7 +166,10 @@ function convertRow(message: NormalizedMessage, attachedResult: AttachedResult): } if (message.kind === 'thinking') { - if (message.content?.trim()) output.push({ type: 'assistant', content: unescapeWithMathProtection(message.content), timestamp: message.timestamp, isThinking: true, ...common }); + // The live reasoning preview (`__thinking_`) streams until its + // `thinking` record replaces it. + const live = Boolean(message.id?.startsWith('__thinking_')); + if (message.content?.trim()) output.push({ type: 'assistant', content: unescapeWithMathProtection(message.content), timestamp: message.timestamp, isThinking: true, ...(live ? { isStreaming: true } : {}), ...common }); return output; } if (message.kind === 'error') { diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.dom.bun.test.tsx b/src/components/chat/hooks/useChatRealtimeHandlers.dom.bun.test.tsx index e2e0945b..44ee36ef 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatRealtimeHandlers.dom.bun.test.tsx @@ -396,3 +396,37 @@ test('a frame that names no session is never attached to the open conversation', assert.deepEqual(view.result.current.getMessages('visible'), []); assert.equal(view.result.current.getSessionSlot('visible')?.status, undefined); }); + +test('reasoning streams into one live row that its thinking record replaces', async () => { + // A long reasoning phase used to show nothing until it ended, while the + // terminal streamed it. The live row is a preview; the record replaces it. + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = renderHook(useSessionStore, { + wrapper: ({ children }) => createElement(QueryClientProvider, { client }, children), + }); + const { send } = mount(view.result.current); + const rows = () => view.result.current.getSessionSlot('visible')?.realtimeMessages ?? []; + send({ kind: 'thinking_delta', sessionId: 'visible', content: 'Weigh ', timestamp: '2026-01-01T00:00:01Z' } as ServerEvent); + send({ kind: 'thinking_delta', sessionId: 'visible', content: 'the options' } as ServerEvent); + send({ kind: 'thinking_delta', sessionId: 'background', content: 'not on screen' } as ServerEvent); + assert.equal(view.result.current.getSessionSlot('visible')?.status, 'streaming'); + await waitFor(() => assert.equal(rows().find((row) => row.id === '__thinking_visible')?.content, 'Weigh the options')); + assert.equal(view.result.current.getSessionSlot('background')?.realtimeMessages.some((row) => row.id === '__thinking_background') ?? false, false); + + const live = rows().filter((row) => row.id === '__thinking_visible'); + const [chat] = normalizedToChatMessages(live); + assert.equal(chat?.isThinking, true); + assert.equal(chat?.isStreaming, true, 'the live row renders as streaming reasoning'); + + send({ kind: 'thinking', sessionId: 'visible', id: 'thinking-1', content: 'Weigh the options' } as ServerEvent); + assert.deepEqual(rows().filter((row) => row.kind === 'thinking').map((row) => row.id), ['thinking-1']); + const [record] = normalizedToChatMessages(rows().filter((row) => row.kind === 'thinking')); + assert.equal(record?.isStreaming, undefined); + + // A second phase in the same turn starts a fresh preview, and the end of + // the turn clears one that never got its record. + send({ kind: 'thinking_delta', sessionId: 'visible', content: 'Second pass' } as ServerEvent); + await waitFor(() => assert.equal(rows().find((row) => row.id === '__thinking_visible')?.content, 'Second pass')); + send({ kind: 'complete', sessionId: 'visible', exitCode: 0 } as ServerEvent); + assert.equal(rows().some((row) => row.id === '__thinking_visible'), false); +}); diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 4e03647f..15e8a50c 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -34,7 +34,7 @@ interface UseChatRealtimeHandlersArgs { const skipsStore = new Set(['complete', 'status', 'permission_request', 'permission_cancelled']); /** Frames that only a live turn produces: their arrival is what "streaming" means. */ -const STREAMING_TURN_KINDS = new Set(['stream_delta', 'stream_end', 'text', 'thinking', 'tool_use', 'tool_result', 'permission_request']); +const STREAMING_TURN_KINDS = new Set(['stream_delta', 'stream_end', 'text', 'thinking', 'thinking_delta', 'tool_use', 'tool_result', 'permission_request']); export function useChatRealtimeHandlers({ subscribe, provider, selectedSession, currentSessionId, setTokenBudget, setSessionState, @@ -72,6 +72,21 @@ export function useChatRealtimeHandlers({ pendingRequests.current = next; setPendingPermissionRequests(next); }; + // Live reasoning, one accumulator per session, painted at most once a frame. + const thinking = new Map(); + let thinkingFrame: number | null = null; + const paintThinking = () => { + thinkingFrame = null; + for (const [id, live] of thinking) sessionStore.updateThinking(id, live.content, provider, live.timestamp); + }; + const endThinking = (sessionId: string) => { + if (!thinking.delete(sessionId) && !sessionStore.getSessionSlot(sessionId)?.realtimeMessages.some(message => message.id === `__thinking_${sessionId}`)) return; + if (thinkingFrame !== null && thinking.size === 0) { + cancelAnimationFrame(thinkingFrame); + thinkingFrame = null; + } + sessionStore.clearThinking(sessionId); + }; const flushStreaming = (sessionId: string | null | undefined, finalizeEmpty: boolean, timestamp?: unknown) => { stopStreamTimer(); if (sessionId && (accumulatedStreamRef.current || finalizeEmpty)) { @@ -94,6 +109,7 @@ export function useChatRealtimeHandlers({ if (before !== after) { if (sessionId === visible) flushStreaming(sessionId, true); else sessionStore.finalizeStreaming(sessionId); + endThinking(sessionId); } } // Subscription responses may race and replay the same frames twice. @@ -146,6 +162,23 @@ export function useChatRealtimeHandlers({ } if (event.kind === 'session_upserted' || event.kind === 'loading_progress') return; + if (event.kind === 'thinking_delta') { + const content = typeof event.content === 'string' ? event.content : ''; + // Only the session on screen shows a live preview; the `thinking` + // record reaches every session regardless. + if (!content || !sessionId || sessionId !== visible) return; + // This effect re-subscribes whenever its inputs change (a permission + // card, a navigation); the store's live row carries the phase across. + const live = thinking.get(sessionId) ?? (() => { + const row = sessionStore.getSessionSlot(sessionId)?.realtimeMessages.find(message => message.id === `__thinking_${sessionId}`); + return row ? { content: row.content ?? '', timestamp: row.timestamp } : undefined; + })(); + thinking.set(sessionId, { content: (live?.content ?? '') + content, timestamp: live?.timestamp ?? event.timestamp }); + if (thinkingFrame === null) thinkingFrame = requestAnimationFrame(paintThinking); + return; + } + if (sessionId && (event.kind === 'thinking' || event.kind === 'complete')) endThinking(sessionId); + if (event.kind === 'stream_delta') { const content = (event.content as string) || ''; if (!content) return; @@ -273,6 +306,10 @@ export function useChatRealtimeHandlers({ sessionStore.updateStreaming(subscribedSession, accumulatedStreamRef.current, provider, pendingStreamTimestamp); } stopStreamTimer(); + if (thinkingFrame !== null) { + cancelAnimationFrame(thinkingFrame); + paintThinking(); + } }; }, [ subscribe, provider, selectedSession, currentSessionId, setTokenBudget, setSessionState, diff --git a/src/components/chat/view/MessageComponent.tsx b/src/components/chat/view/MessageComponent.tsx index 35659d67..a82ac337 100644 --- a/src/components/chat/view/MessageComponent.tsx +++ b/src/components/chat/view/MessageComponent.tsx @@ -419,7 +419,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, d ) : message.isThinking ? ( /* Thinking messages — Reasoning component (ai-elements pattern) */ - + diff --git a/src/hooks/useSessionAttentionSync.ts b/src/hooks/useSessionAttentionSync.ts index 6f9fae44..29adfe5e 100644 --- a/src/hooks/useSessionAttentionSync.ts +++ b/src/hooks/useSessionAttentionSync.ts @@ -18,7 +18,7 @@ type UseSessionAttentionSyncArgs = { const asksForDecision = (toolName: unknown) => toolName !== 'ExitPlanMode' && toolName !== 'exit_plan_mode'; /** Events a run emits while it is doing work; seeing one for a session means its last outcome is history. */ -const LIVE_RUN_EVENTS = new Set(['stream_delta', 'text', 'tool_use', 'tool_result', 'thinking', 'status']); +const LIVE_RUN_EVENTS = new Set(['stream_delta', 'text', 'tool_use', 'tool_result', 'thinking', 'thinking_delta', 'status']); const sessionOf = (event: ServerEvent): string | null => (typeof event.sessionId === 'string' && event.sessionId ? event.sessionId : null); diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 0ba526ba..51135581 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -7,7 +7,7 @@ import { authenticatedFetch } from '../utils/api'; import { buildRefreshMessagesUrl, shareMessageWindow } from './sessionMessageFetch'; -type MessageKind = 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end' | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' | 'session_created' | 'interactive_prompt' | 'task_notification' | 'system_notice' | 'delegation_updated'; +type MessageKind = 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'thinking_delta' | 'stream_delta' | 'stream_end' | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' | 'session_created' | 'interactive_prompt' | 'task_notification' | 'system_notice' | 'delegation_updated'; /** * The public snapshot of an App-owned delegation, as the durable receipt in * the owner transcript records it. It is deliberately the settlement signal @@ -451,12 +451,34 @@ export function useSessionStore() { // moved with every delta would tick that duration up as the answer streams. const updateStreaming = useCallback((id: string, content: string, provider: LLMProvider, timestamp?: unknown) => { const slot = getSlot(id); const streamId = `__streaming_${id}`; const position = slot.realtimeMessages.findIndex((message) => message.id === streamId); const startedAt = typeof timestamp === 'string' && Number.isFinite(Date.parse(timestamp)) ? timestamp : new Date().toISOString(); const row: NormalizedMessage = { id: streamId, sessionId: id, timestamp: position < 0 ? startedAt : slot.realtimeMessages[position].timestamp, provider, kind: 'stream_delta', content }; slot.realtimeMessages = position < 0 ? [...slot.realtimeMessages, row] : slot.realtimeMessages.map((message, index) => index === position ? row : message); refreshMerged(slot); emitSession(id); }, [emitSession, getSlot]); const finalizeStreaming = useCallback((id: string) => { const slot = slots.current.get(id); if (!slot) return; const streamId = `__streaming_${id}`; const position = slot.realtimeMessages.findIndex((message) => message.id === streamId); if (position < 0) return; slot.realtimeMessages = slot.realtimeMessages.map((message, index) => index === position ? { ...message, id: `text_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, kind: 'text', role: 'assistant' } : message); refreshMerged(slot); emitSession(id); }, [emitSession]); + // A reasoning phase streams into one live row per session. It is a preview: + // the `thinking` frame at the end of the phase is the record, so the live + // row is dropped when that frame (or the end of the turn) arrives. + const updateThinking = useCallback((id: string, content: string, provider: LLMProvider, timestamp?: unknown) => { + const slot = getSlot(id); + const liveId = `__thinking_${id}`; + const position = slot.realtimeMessages.findIndex((message) => message.id === liveId); + const startedAt = typeof timestamp === 'string' && Number.isFinite(Date.parse(timestamp)) ? timestamp : new Date().toISOString(); + const row: NormalizedMessage = { id: liveId, sessionId: id, timestamp: position < 0 ? startedAt : slot.realtimeMessages[position].timestamp, provider, kind: 'thinking', content }; + slot.realtimeMessages = position < 0 ? [...slot.realtimeMessages, row] : slot.realtimeMessages.map((message, index) => index === position ? row : message); + refreshMerged(slot); + emitSession(id); + }, [emitSession, getSlot]); + const clearThinking = useCallback((id: string) => { + const slot = slots.current.get(id); + if (!slot) return; + const liveId = `__thinking_${id}`; + if (!slot.realtimeMessages.some((message) => message.id === liveId)) return; + slot.realtimeMessages = slot.realtimeMessages.filter((message) => message.id !== liveId); + refreshMerged(slot); + emitSession(id); + }, [emitSession]); const clearRealtime = useCallback((id: string) => { const slot = slots.current.get(id); if (!slot) return; slot.realtimeMessages = []; refreshMerged(slot); emitSession(id); }, [emitSession]); const clear = useCallback(() => { const hadActive = activeSession.current !== null; slots.current.clear(); queryClient.removeQueries({ queryKey: ['messages'] }); activeSession.current = null; setObservedSession(null); if (hadActive) redraw((version) => version + 1); }, [queryClient]); const getMessages = useCallback((id: string) => { const slot = slots.current.get(id); if (!slot) return EMPTY; refreshMerged(slot); return slot.merged; }, []); const getSessionSlot = useCallback((id: string) => { const slot = slots.current.get(id); if (slot) refreshMerged(slot); return slot; }, []); - return useMemo(() => ({ getSlot, acceptRealtimeEvent, getReplayCursor, trackReplayFrame, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, clearRealtime, clear, getJobSlot, getJobCursor, setActiveJob, applyJobSubscribed, applyJobReplayChunk, applyJobLiveEvent, setJobError, clearJobs, getMessages, getSessionSlot, subscribeSession }), [getSlot, acceptRealtimeEvent, getReplayCursor, trackReplayFrame, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, clearRealtime, clear, getJobSlot, getJobCursor, setActiveJob, applyJobSubscribed, applyJobReplayChunk, applyJobLiveEvent, setJobError, clearJobs, getMessages, getSessionSlot, subscribeSession]); + return useMemo(() => ({ getSlot, acceptRealtimeEvent, getReplayCursor, trackReplayFrame, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, updateThinking, clearThinking, clearRealtime, clear, getJobSlot, getJobCursor, setActiveJob, applyJobSubscribed, applyJobReplayChunk, applyJobLiveEvent, setJobError, clearJobs, getMessages, getSessionSlot, subscribeSession }), [getSlot, acceptRealtimeEvent, getReplayCursor, trackReplayFrame, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, updateThinking, clearThinking, clearRealtime, clear, getJobSlot, getJobCursor, setActiveJob, applyJobSubscribed, applyJobReplayChunk, applyJobLiveEvent, setJobError, clearJobs, getMessages, getSessionSlot, subscribeSession]); } export type SessionStore = ReturnType; From 2f6af63daabbb933c3333ec33f985d6855e143c1 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:07 +0900 Subject: [PATCH 3/5] fix(core): backfill only directories that appeared macOS reports a metadata change on the session-scope directory for every atomic transcript write. The watcher rescanned that directory each time; a scope with 25k entries overflowed the 4096-entry bound and restarted the watcher into a full reconciliation over and over. --- native/gajae-core/src/watcher.rs | 68 +++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/native/gajae-core/src/watcher.rs b/native/gajae-core/src/watcher.rs index af77ee08..df5753cd 100644 --- a/native/gajae-core/src/watcher.rs +++ b/native/gajae-core/src/watcher.rs @@ -134,6 +134,13 @@ fn write_event_frames( let Some((kind, destination_only)) = output_event(event.kind) else { return true; }; + // Only a directory that has just appeared under a root can hold + // transcripts no event described. An existing directory reports metadata + // changes every time an entry inside it is created or renamed (macOS + // FSEvents does so for each atomic transcript write), and rescanning a + // session scope with thousands of entries on each one overflowed the + // bounded scan and restarted the watcher into a full reconciliation. + let introduces_directories = directory_may_have_appeared(event.kind); let paths: &[PathBuf] = if destination_only { event.paths.last().map_or(&[], std::slice::from_ref) @@ -146,7 +153,10 @@ fn write_event_frames( return false; } } - if is_directory(path) && !backfills.iter().any(|(pending, _)| pending == path) { + if introduces_directories + && is_directory(path) + && !backfills.iter().any(|(pending, _)| pending == path) + { if backfills.len() >= MAX_PENDING_BACKFILLS { return false; } @@ -235,6 +245,17 @@ fn output_event(kind: EventKind) -> Option<(OutputEvent, bool)> { } } +fn directory_may_have_appeared(kind: EventKind) -> bool { + use notify::event::{ModifyKind, RenameMode}; + matches!( + kind, + EventKind::Create(_) + | EventKind::Modify(ModifyKind::Name( + RenameMode::To | RenameMode::Both | RenameMode::Any | RenameMode::Other + )) + ) +} + fn frame_for_path(kind: OutputEvent, path: &Path, roots: &[PathBuf]) -> Option> { let resolved = std::fs::canonicalize(path).ok()?; frame_for_resolved_path(kind, &resolved, roots) @@ -456,6 +477,51 @@ mod tests { fs::remove_dir_all(container).unwrap(); } + #[test] + fn metadata_changes_on_an_existing_directory_do_not_rescan_it() { + // An atomic transcript write renames an entry inside the session scope + // directory, and FSEvents reports that directory's metadata change. + // Treating it as a new directory scanned the whole scope on every + // write and failed the watcher once the scope outgrew the scan bound. + let container = scratch_directory("metadata-directory"); + fs::create_dir(&container).unwrap(); + let root = fs::canonicalize(&container).unwrap(); + for index in 0..=MAX_BACKFILL_ENTRIES { + fs::write(root.join(format!("{index}.jsonl")), b"{}\n").unwrap(); + } + let mut backfills = VecDeque::new(); + let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Metadata( + notify::event::MetadataKind::Any, + ))) + .add_path(root.clone()); + assert!(write_event_frames( + &mut Vec::new(), + std::slice::from_ref(&root), + event, + &mut backfills, + )); + assert!(backfills.is_empty()); + + // A directory that was created or renamed in is still backfilled. + for kind in [ + EventKind::Create(notify::event::CreateKind::Folder), + EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::To, + )), + ] { + let mut backfills = VecDeque::new(); + let event = Event::new(kind).add_path(root.clone()); + assert!(write_event_frames( + &mut Vec::new(), + std::slice::from_ref(&root), + event, + &mut backfills, + )); + assert_eq!(backfills.len(), 1, "{kind:?}"); + } + fs::remove_dir_all(container).unwrap(); + } + #[test] fn oversized_backfill_requests_reconciliation_instead_of_partial_success() { let container = scratch_directory("backfill-scan-overflow"); From d3417fe3343decfdb3585d39e916fcec417d2b2d Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:07 +0900 Subject: [PATCH 4/5] test: keep Bun test sessions out of the operator's crash journal SDK sessions in the Bun suites wrote their handled errors into ~/.gjc, where they read as app crashes (8 of 12 post-0.17.6 records). Each Bun test file now runs with a throwaway HOME; git keeps the operator's global config. --- scripts/run-tests.mjs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f77409f8..4d3552a5 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1,4 +1,6 @@ +import { mkdtempSync, rmSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -81,7 +83,18 @@ function isolatedTestEnvironment() { for (const name of ['TMUX', 'TMUX_PANE', 'KITTY_WINDOW_ID', 'TERM_SESSION_ID', 'WT_SESSION']) { delete env[name]; } - return env; + // The SDK writes its log, crash journal and handled-error records under + // `~/.gjc`. Test sessions that fail on purpose would otherwise land in the + // operator's real crash journal and daily log as if the app had crashed, + // which is the evidence #158/#162 are judged on. A throwaway home keeps + // them out; git keeps the operator's global config so commit fixtures work. + const home = mkdtempSync(path.join(os.tmpdir(), 'gjc-test-home-')); + env.GIT_CONFIG_GLOBAL = process.env.GIT_CONFIG_GLOBAL ?? path.join(os.homedir(), '.gitconfig'); + env.HOME = home; + for (const name of ['GJC_CODING_AGENT_DIR', 'PI_CODING_AGENT_DIR', 'GJC_CONFIG_DIR', 'PI_CONFIG_DIR', 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME']) { + delete env[name]; + } + return { env, dispose: () => rmSync(home, { recursive: true, force: true }) }; } function runBunTests(label, files) { @@ -99,11 +112,13 @@ function runBunTests(label, files) { // Keep each contract file isolated so leaked globals, timers, or worker state // cannot make the aggregate Bun phase order-dependent. for (const file of files) { + const isolated = isolatedTestEnvironment(); const result = spawnSync(bun.path, ['test', file], { cwd: process.cwd(), - env: isolatedTestEnvironment(), + env: isolated.env, stdio: ['ignore', 'inherit', 'inherit'], }); + isolated.dispose(); if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); } From 0264d2cbfd82d7887181414421a9bdd4f7a1ca60 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:09 +0900 Subject: [PATCH 5/5] docs: record the CLI-parity pass and the runtime-owned service tier Account selection and the service tier follow the runtime, as in the CLI; the handoff records the parity matrix, the fixes, the remaining gaps and the beta.20 blocker. --- docs/V2-SESSION-HANDOFF.md | 65 ++++++++++++++++++++++++++++++++++++++ server/GJC-LIVE-SPEC.md | 21 +++++++++--- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/V2-SESSION-HANDOFF.md b/docs/V2-SESSION-HANDOFF.md index 8641f1a8..dbe140f3 100644 --- a/docs/V2-SESSION-HANDOFF.md +++ b/docs/V2-SESSION-HANDOFF.md @@ -1,5 +1,70 @@ # gajae-app v2 — Session Handoff (resume state) +## CLI-parity adversarial pass on SDK 0.17.6 (2026-09-25) + +The same prompts ran through the app's public WebSocket (`chat.send`, the path +the browser uses) and `gjc -p` in one fixture repo, which was reset between +runs. Four defects surfaced and were fixed on +`fix/cli-parity-credential-thinking-watcher`: + +- **Account selection (blocker).** With several stored rows for a provider, + the adapter pinned the lowest row id. For Anthropic that was an account + whose organization refuses OAuth, so every app turn failed with 403 while + the CLI answered: the runtime's own selection honours `gjc accounts pin`, + routing exclusions and usage-limit rotation. The app now pins only an + explicit `credentialId` and reports the row the runtime chose. +- **Reasoning arrived only at the end of the phase.** The CLI streams + reasoning deltas. The app now sends `thinking_delta` previews into one live + row per session, and the `thinking` record replaces it. It is visible + under the Detailed density. Balanced and Compact hide reasoning by design + and show the `Thinking… Ns` indicator. +- **Native session watcher restart loop.** macOS reports a metadata change on + the session-scope directory for every atomic transcript write. The watcher + treated each as a new directory and rescanned 25k entries, overflowed its + 4096 bound and restarted into a full reconciliation (16 restarts in a few + minutes of dev). It now backfills only created or renamed directories. +- **Tests wrote into the operator's crash journal.** Bun test files now get a + throwaway `HOME`. Eight of the twelve post-0.17.6 "crash" records were + test runs. + +Parity confirmed: edit+bash result and diff, project `AGENTS.md` rules, the +skill list, user-scope MCP (none), `task`/`subagent` delegation, large bash +output (same 8 MiB spill), multi-turn memory, abort mid-bash (no orphan +process), resume, steering mid-run, three concurrent sessions (24 s wall for +3×10 s sleeps), and `/fast` `/effort` `/context` `/usage` `/tools`, which run +in the app but not under `gjc -p`. Latency for the same one-word turn: app +11.6–11.9 s, CLI 13.5–13.8 s (the CLI pays process start). + +Intentional gaps (see `server/gjc-agent-tools.ts`): no `python`/`eval`, +`job`/`monitor`, `github`, `debug`, `checkpoint`/`rewind`, tool discovery or +`move_session`. Project `.gjc/mcp.json` and extension modules do not load. + +Observed, not fixed: + +- `ChatInterface` calls `sessionStore.setActiveSession` during render. This is + a React "Cannot update a component while rendering" warning on every + session open, present since the initial commit. +- Zero-delay synthetic typing (puppeteer) after a reload trips "Maximum + update depth" in the composer's `resize`. It does not reproduce at a 10 ms + keystroke delay. + +Issues: + +- #160 is answered: the tier is the runtime's, as in the CLI. It comes from + `serviceTier` (default `none`) or per-session `/fast`, and the app pins + none. Checked live: no tier, then `priority` after `/fast on`, then none + after `/fast off`. +- #158 stays open. 0.17.6 fixed denials and non-zero bash exits, but hook + refusals and tool input errors are still recorded (upstream + [#5938](https://github.com/Yeachan-Heo/gajae-code/issues/5938)). +- #162 stays open for usage polling only: 29 processes, 89×429 in about six + hours (upstream [#5939](https://github.com/Yeachan-Heo/gajae-code/issues/5939)). + MCP timeouts, `Invalid port` and the `notify` collision are gone. + +beta.20 is blocked on the owner. `notarytool` reports `keychainLocked` and +the updater key password is held off this Mac, so signing, notarization and +publication need the owner at the keyboard. + ## Post-beta.14 checkpoint — beta.15→19 shipped, checkout isolation closed (2026-09-19) Five releases shipped without a handoff entry; release notes and published diff --git a/server/GJC-LIVE-SPEC.md b/server/GJC-LIVE-SPEC.md index dc71ad42..8f79adf7 100644 --- a/server/GJC-LIVE-SPEC.md +++ b/server/GJC-LIVE-SPEC.md @@ -231,8 +231,14 @@ method or frame changes; the policy travels inside existing payloads: the app itself produced — and the application relays the fixed text "Invalid GJC run permissions." to the client instead of the generic "GJC worker failed.". -- A run's model must pair with a credential the runtime can use. Stored rows - pin deterministically as before; a provider with **no** stored row is still +- A run's model must pair with a credential the runtime can use. With stored + rows and no explicit `credentialId` the run starts with no + `credentialSelector`: the runtime chooses the account exactly as the CLI + does (`gjc accounts pin`, routing exclusions, usage-limit rotation), and + the run result reports the row it settled on. Pinning the lowest row id + instead sent every run to an account the CLI never used (observed: an + Anthropic account whose organization refuses OAuth). Only an explicit + `credentialId` installs a selector. A provider with **no** stored row is still eligible when the auth layer can resolve a key for it (`models.yml` `apiKey`/`apiKeyEnv`, env fallback — probed via `peekApiKey`, which resolves nothing), and such a run starts with no `credentialSelector` so the runtime @@ -253,8 +259,15 @@ method or frame changes; the policy travels inside existing payloads: rejected for this model; retried without it. Fast mode is off for this model until you re-enable it with /fast on.") is omitted from chat rows the same way, at any level and with or without the `priority: ` source prefix. The - turn already ran without priority, the app exposes no fast-mode control, and - the runtime re-warns once per model in every session. The notice is still + turn already ran without priority, and the runtime re-warns once per model + in every session. +- The service tier is the runtime's, as in the CLI: the user's `serviceTier` + setting (default `none`, which omits `service_tier`) and the per-session + `/fast on|off|status` command, which the app's slash menu carries. The app + pins no tier and overrides none; a run reports the tier it resolved to in + its session snapshot and the Agent sidebar shows it only when one is set. + Verified on SDK 0.17.6: a new session reports no tier, `/fast on` makes the + next turn report `priority`, `/fast off` removes it (#160). The notice is still recorded, exported and forwarded; only the chat row is dropped. Any other wording, including a different source prefix or extra text, stays visible. - Any other gated call is an `ask.presented` event whose message is a