From bd1ba9b4e7051f37c06e332dc1eac527518a4162 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 09:32:05 -0400 Subject: [PATCH 01/36] fix(vscode): attribute DynamicWorkflow steps to their own subagent Every subagent in a DynamicWorkflow batch shares one parentToolCallId, so the webview funnelled all of them into a single flat subagent_steps array and targeted the array tail as the "current step". Two agents streaming at once wrote text, thinking, and tool calls into each other's steps. Steps now carry the emitting agent's identity and are targeted per agent. The subagent lifecycle events the adapter already received but discarded (started/completed/failed/suspended) are mapped to a SubagentStatus event, which gives each lane a status, a duration, and a result or error. On top of that attribution, DynamicWorkflow renders as per-agent lanes instead of an escaped-JSON argument dump: live activity, a step count, a status, and a progress bar filled relative to the busiest lane. There is no per-agent step total to divide by, so an absolute percentage would be fabricated; the caption names the denominator. Lane derivation lives in lib/workflow-lanes.ts rather than the store module, and the pure tool-argument helpers move to lib/tool-args.ts. Tool rendering is mutually recursive, so WorkflowCard receives its step-item renderer as a prop rather than importing it and closing an import cycle. The new test file is excluded from the extension tsconfig, which is where the webview-side tests are kept out of the extension program. --- apps/vscode/shared/legacy-sdk.ts | 17 ++ apps/vscode/src/runtime/event-adapter.ts | 80 +++++- apps/vscode/src/runtime/replay-adapter.ts | 3 + apps/vscode/test/event-adapter.test.ts | 74 ++++++ apps/vscode/test/event-handlers.test.ts | 244 ++++++++++++++++++ apps/vscode/test/replay-adapter.test.ts | 2 + .../test/replay-resume.integration.test.ts | 2 + apps/vscode/tsconfig.json | 9 +- .../webview-ui/src/components/ChatMessage.tsx | 2 +- .../src/components/ToolRenderers.tsx | 58 ++--- .../src/components/WorkflowCard.tsx | 136 ++++++++++ apps/vscode/webview-ui/src/lib/tool-args.ts | 34 +++ .../webview-ui/src/lib/workflow-lanes.ts | 63 +++++ .../webview-ui/src/stores/chat.store.ts | 14 + .../webview-ui/src/stores/event-handlers.ts | 179 ++++++++++--- apps/vscode/webview-ui/tsconfig.json | 2 +- 16 files changed, 827 insertions(+), 92 deletions(-) create mode 100644 apps/vscode/test/event-handlers.test.ts create mode 100644 apps/vscode/webview-ui/src/components/WorkflowCard.tsx create mode 100644 apps/vscode/webview-ui/src/lib/tool-args.ts create mode 100644 apps/vscode/webview-ui/src/lib/workflow-lanes.ts diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index d006c209..49e4b6e5 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -126,6 +126,22 @@ export interface QuestionResponse { export interface SubagentEvent { parent_tool_call_id: string; event: LegacyWireEvent; + /** Emitting subagent. */ + agent_id: string; + /** Display name for the lane, e.g. "explore". Absent for non-DynamicWorkflow subagents. */ + agent_label?: string; + /** Position within the DynamicWorkflow batch, 1-based. Absent when the subagent has none. */ + agent_index?: number; +} + +export interface SubagentStatusPayload { + parent_tool_call_id: string; + agent_id: string; + agent_label?: string; + agent_index?: number; + status: 'spawned' | 'running' | 'done' | 'failed' | 'suspended'; + error?: string; + result_summary?: string; } export type LegacyWireEvent = @@ -142,6 +158,7 @@ export type LegacyWireEvent = | { type: 'ToolResult'; payload: ToolResult } | { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } } | { type: 'SubagentEvent'; payload: SubagentEvent } + | { type: 'SubagentStatus'; payload: SubagentStatusPayload } | { type: string; payload: unknown }; export type StreamEvent = diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts index dfefa119..0cfa7c32 100644 --- a/apps/vscode/src/runtime/event-adapter.ts +++ b/apps/vscode/src/runtime/event-adapter.ts @@ -4,6 +4,7 @@ import type { DisplayBlock, LegacyWireEvent, StatusUpdate, + SubagentStatusPayload, TokenUsage, TurnBegin, } from '../../shared/legacy-sdk'; @@ -22,6 +23,9 @@ export interface AdapterTokenUsage { export interface SubagentParent { readonly parentAgentId: string; readonly parentToolCallId: string; + readonly subagentName?: string; + readonly description?: string; + readonly dynamicWorkflowIndex?: number; } export interface EventAdapterState { @@ -89,22 +93,35 @@ export function adaptSdkEvent( if (sdkEvent.type === 'subagent.spawned') { const parentAgentId = (sdkEvent as any).parentAgentId ?? (sdkEvent as any).callerAgentId ?? sdkEvent.agentId; - return { - state: { - ...state, - subagentParents: { - ...state.subagentParents, - [sdkEvent.subagentId]: { - parentAgentId, - parentToolCallId: scopedToolCallId( - parentAgentId, - sdkEvent.parentToolCallId, - mainAgentId, - ), - }, + const parentToolCallId = scopedToolCallId(parentAgentId, sdkEvent.parentToolCallId, mainAgentId); + const nextState: EventAdapterState = { + ...state, + subagentParents: { + ...state.subagentParents, + [sdkEvent.subagentId]: { + parentAgentId, + parentToolCallId, + subagentName: sdkEvent.subagentName, + description: sdkEvent.description, + dynamicWorkflowIndex: sdkEvent.dynamicWorkflowIndex, }, }, }; + const statusPayload: SubagentStatusPayload = { + parent_tool_call_id: parentToolCallId, + agent_id: sdkEvent.subagentId, + agent_label: sdkEvent.subagentName, + agent_index: sdkEvent.dynamicWorkflowIndex, + status: 'spawned', + }; + const routed = routeSubagentEvent( + nextState, + parentAgentId, + { type: 'SubagentStatus', payload: statusPayload }, + mainAgentId, + ); + if (routed === undefined) return { state: nextState }; + return { state: nextState, event: withSessionId(routed, sdkEvent.sessionId) }; } if (sdkEvent.type === 'turn.started') { @@ -320,6 +337,14 @@ function mapLegacyWireEvent( } case 'agent.status.updated': return mapStatusUpdate(state, sdkEvent); + case 'subagent.started': + return mapSubagentStatus(state, sdkEvent, 'running'); + case 'subagent.completed': + return mapSubagentStatus(state, sdkEvent, 'done'); + case 'subagent.failed': + return mapSubagentStatus(state, sdkEvent, 'failed'); + case 'subagent.suspended': + return mapSubagentStatus(state, sdkEvent, 'suspended'); case 'compaction.started': return { state, @@ -368,6 +393,32 @@ function mapStatusUpdate( }; } +function mapSubagentStatus( + state: EventAdapterState, + sdkEvent: Extract< + Event, + { type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.suspended' } + >, + status: SubagentStatusPayload['status'], +): MappedLegacyWireEvent { + // subagent.spawned always precedes every other lifecycle event for the same + // subagentId, so the parent is always known by the time this runs. + const parent = state.subagentParents[sdkEvent.subagentId]; + if (parent === undefined) return { state }; + + const payload: SubagentStatusPayload = { + parent_tool_call_id: parent.parentToolCallId, + agent_id: sdkEvent.subagentId, + agent_label: parent.subagentName, + agent_index: parent.dynamicWorkflowIndex, + status, + error: sdkEvent.type === 'subagent.failed' ? sdkEvent.error : undefined, + result_summary: sdkEvent.type === 'subagent.completed' ? sdkEvent.resultSummary : undefined, + }; + + return { state, event: { type: 'SubagentStatus', payload } }; +} + function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage { return { input_other: delta(current.inputOther, previous?.inputOther), @@ -414,6 +465,9 @@ function routeSubagentEvent( type: 'SubagentEvent', payload: { parent_tool_call_id: parent.parentToolCallId, + agent_id: currentAgentId, + agent_label: parent.subagentName, + agent_index: parent.dynamicWorkflowIndex, event: routed, }, }; diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts index bd51f226..83f544bf 100644 --- a/apps/vscode/src/runtime/replay-adapter.ts +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -496,6 +496,9 @@ function wrapSubagentEvent( invocation.parentAgentId, invocation.parentToolCallId, ), + // Replay history carries no persisted label or dynamicWorkflowIndex; the UI + // falls back to the id's short form. + agent_id: invocation.childAgentId, event: routed, }, }; diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts index e910edfa..04a18520 100644 --- a/apps/vscode/test/event-adapter.test.ts +++ b/apps/vscode/test/event-adapter.test.ts @@ -360,6 +360,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', type: 'SubagentEvent', payload: { parent_tool_call_id: 'agent-call-1', + agent_id: 'child-1', + agent_label: 'coder', event: { type: 'ContentPart', payload: { type: 'text', text: 'Child result' }, @@ -394,6 +396,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', type: 'SubagentEvent', payload: { parent_tool_call_id: 'agent-call-1', + agent_id: 'child-1', + agent_label: 'coder', event: { type: 'ToolCall', payload: { @@ -410,6 +414,76 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', }); }); + it('emits a spawned SubagentStatus the moment a subagent is queued', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'explore', + parentToolCallId: 'wf-1', + parentAgentId: 'main', + dynamicWorkflowIndex: 2, + runInBackground: false, + }); + + expect(result.event).toEqual({ + type: 'SubagentStatus', + payload: { + parent_tool_call_id: 'wf-1', + agent_id: 'child-1', + agent_label: 'explore', + agent_index: 2, + status: 'spawned', + }, + _sessionId: 'session-1', + }); + }); + + it.each([ + ['subagent.started' as const, 'running' as const, {}], + ['subagent.suspended' as const, 'suspended' as const, { reason: 'waiting on approval' }], + ['subagent.completed' as const, 'done' as const, { resultSummary: 'Explored 3 files' }], + ['subagent.failed' as const, 'failed' as const, { error: 'timed out' }], + ])('routes each %s lifecycle event to one SubagentStatus on the spawning tool call', (sdkType, status, extra) => { + const spawned = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'explore', + parentToolCallId: 'wf-1', + parentAgentId: 'main', + dynamicWorkflowIndex: 2, + runInBackground: false, + }); + + const lifecycle = adaptSdkEvent(spawned.state, { + type: sdkType, + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + parentToolCallId: 'wf-1', + ...extra, + } as any); + + const expectedPayload: Record = { + parent_tool_call_id: 'wf-1', + agent_id: 'child-1', + agent_label: 'explore', + agent_index: 2, + status, + }; + if ('error' in extra) expectedPayload['error'] = (extra as { error: string }).error; + if ('resultSummary' in extra) expectedPayload['result_summary'] = (extra as { resultSummary: string }).resultSummary; + + expect(lifecycle.event).toEqual({ + type: 'SubagentStatus', + payload: expectedPayload, + _sessionId: 'session-1', + }); + }); + it('emits compaction begin when SDK compaction starts', () => { const result = adaptSdkEvent(createEventAdapterState(), { type: 'compaction.started', diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts new file mode 100644 index 00000000..daebb7f5 --- /dev/null +++ b/apps/vscode/test/event-handlers.test.ts @@ -0,0 +1,244 @@ +/** + * Scenario: `DynamicWorkflow` batches stream events for many subagents that share one + * parent tool call. Store-level event handling must attribute each agent's steps to that + * agent alone, and per-agent status/lane derivation must reflect the wire protocol. + * Wiring: the real Zustand chat store; the VS Code bridge is the only replaced boundary. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/event-handlers.test.ts + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + saveConfig: vi.fn(), + streamChat: vi.fn(), + abortChat: vi.fn(), + trackFiles: vi.fn(), + toastError: vi.fn(), + toastWarning: vi.fn(), +})); + +vi.mock("@/services", () => ({ + bridge: { + saveConfig: boundary.saveConfig, + streamChat: boundary.streamChat, + abortChat: boundary.abortChat, + trackFiles: boundary.trackFiles, + }, +})); +vi.mock("@/components/ui/sonner", () => ({ + toast: { error: boundary.toastError, warning: boundary.toastWarning }, +})); + +import { useChatStore } from "../webview-ui/src/stores/chat.store"; +import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; +import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; + +beforeEach(() => { + boundary.streamChat.mockReset(); + boundary.abortChat.mockReset(); + boundary.trackFiles.mockReset(); + useChatStore.setState({ + sessionId: null, + messages: [], + isStreaming: false, + isCompacting: false, + handshakeReceived: false, + draftMedia: [], + lastStatus: null, + tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + activeTokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + pendingInput: null, + queue: [], + pendingQuestion: null, + planMode: false, + }); +}); + +function startWorkflowTurn() { + useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "run workflow" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); + useChatStore.getState().processEvent({ + type: "ToolCall", + payload: { type: "function", id: "wf-1", function: { name: "DynamicWorkflow", arguments: "{}" } }, + }); +} + +function workflowToolItem(): UIStepItem & { type: "tool_use" } { + const last = useChatStore.getState().messages.at(-1)!; + const item = last.steps!.at(0)!.items.find((i) => i.type === "tool_use"); + return item as UIStepItem & { type: "tool_use" }; +} + +function wrap(agentId: string, event: { type: string; payload: unknown }, agentIndex?: number) { + return { + type: "SubagentEvent", + payload: { + parent_tool_call_id: "wf-1", + agent_id: agentId, + agent_label: "explore", + agent_index: agentIndex, + event, + }, + }; +} + +describe("Webview DynamicWorkflow per-agent lanes", () => { + it("keeps each subagent's streamed text in its own step when two agents interleave", () => { + startWorkflowTurn(); + + // Agent A opens its step, agent B opens its step, then their text arrives + // out of order. Each agent's text must land only in its own step. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "hello from A" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "hello from B" } }, 2)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: ", continued" } }, 1)); + + const toolItem = workflowToolItem(); + const textFor = (agentId: string) => + toolItem + .subagent_steps!.filter((s) => s.agentId === agentId) + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content) + .join(""); + + expect(textFor("agentA")).toBe("hello from A, continued"); + expect(textFor("agentB")).toBe("hello from B"); + }); + + it("gives an agent exactly one step per StepBegin, with no leading empty step", () => { + startWorkflowTurn(); + + // A lane's step count drives its progress bar, so a seeded placeholder step + // in front of the agent's own StepBegin would inflate every lane by one. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "work" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "work" } }, 2)); + + const steps = workflowToolItem().subagent_steps!; + expect(steps.filter((s) => s.agentId === "agentA")).toHaveLength(1); + expect(steps.filter((s) => s.agentId === "agentB")).toHaveLength(1); + expect(steps.every((s) => s.items.length > 0)).toBe(true); + }); + + it("stamps startedAt/endedAt as a lane's SubagentStatus transitions", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "done", result_summary: "Explored 3 files" }, + }); + + const status = workflowToolItem().subagent_status!["agentA"]!; + expect(status.status).toBe("done"); + expect(status.resultSummary).toBe("Explored 3 files"); + expect(typeof status.startedAt).toBe("number"); + expect(typeof status.endedAt).toBe("number"); + }); + + it("targets the main agent's steps by array tail when no agent is scoped", () => { + // The only remaining no-agent case: applyEventToSteps called directly for + // the main agent's own turn, not through a SubagentEvent wrapper. + useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "hello" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); + useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "first" } }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 2 } }); + useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "second" } }); + + const last = useChatStore.getState().messages.at(-1)!; + expect(last.steps!.every((s) => s.agentId === undefined)).toBe(true); + const texts = last.steps! + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content); + expect(texts).toEqual(["first", "second"]); + }); + + it("does not drop a second agent's content that arrives before that agent's own StepBegin", () => { + startWorkflowTurn(); + + // Agent A is already streaming; agent B's very first event is content, + // arriving before B has emitted its own StepBegin. + useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1)); + useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "from A" } }, 1)); + useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "from B" } }, 2)); + + const toolItem = workflowToolItem(); + const textFor = (agentId: string) => + toolItem + .subagent_steps!.filter((s) => s.agentId === agentId) + .flatMap((s) => s.items) + .filter((i): i is { type: "text"; content: string } => i.type === "text") + .map((i) => i.content) + .join(""); + + expect(textFor("agentB")).toBe("from B"); + expect(textFor("agentA")).toBe("from A"); + }); + + it("marks lanes still spawned/running as failed when the batch's parent ToolResult lands", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentB", agent_label: "explore", agent_index: 2, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentC", agent_label: "explore", agent_index: 3, status: "done" }, + }); + + // The batch is aborted: the parent tool call resolves while agentA (running) + // and agentB (spawned) never received a terminal lifecycle event of their own. + useChatStore.getState().processEvent({ + type: "ToolResult", + payload: { tool_call_id: "wf-1", return_value: { is_error: true, output: "aborted", message: "", display: [] } }, + }); + + const status = workflowToolItem().subagent_status!; + expect(status["agentA"]!.status).toBe("failed"); + expect(typeof status["agentA"]!.endedAt).toBe("number"); + expect(status["agentB"]!.status).toBe("failed"); + expect(typeof status["agentB"]!.endedAt).toBe("number"); + // Already-terminal lanes are left alone. + expect(status["agentC"]!.status).toBe("done"); + }); +}); + +describe("workflow lane derivation", () => { + it("groups steps by agent, orders lanes by agentIndex, and sizes the bar to the busiest lane", () => { + const steps = [ + { n: 1, items: [], agentId: "b", agentLabel: "explore", agentIndex: 2 }, + { n: 1, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 }, + { n: 2, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 }, + ]; + const statuses = { + a: { status: "done" as const }, + b: { status: "running" as const }, + c: { status: "spawned" as const, label: "explore", index: 3 }, + }; + + const lanes = deriveWorkflowLanes(steps, statuses); + + expect(lanes.map((l) => l.agentId)).toEqual(["a", "b", "c"]); + expect(lanes.map((l) => l.stepCount)).toEqual([2, 1, 0]); + expect(maxLaneStepCount(lanes)).toBe(2); + }); +}); diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts index 745002d8..3bc2bae4 100644 --- a/apps/vscode/test/replay-adapter.test.ts +++ b/apps/vscode/test/replay-adapter.test.ts @@ -555,6 +555,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "sub-1", event: { type: "ContentPart", payload: { type: "text", text: "first child answer" } }, }, })); @@ -562,6 +563,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-2", + agent_id: "sub-1", event: { type: "ContentPart", payload: { type: "text", text: "second child answer" } }, }, })); diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts index a7f99c46..6d894990 100644 --- a/apps/vscode/test/replay-resume.integration.test.ts +++ b/apps/vscode/test/replay-resume.integration.test.ts @@ -298,6 +298,7 @@ describe("VS Code replay from a public Node SDK resume state", () => { type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "agent-0", event: { type: "StepBegin", payload: { n: 1 } }, }, }), @@ -307,6 +308,7 @@ describe("VS Code replay from a public Node SDK resume state", () => { type: "SubagentEvent", payload: { parent_tool_call_id: "agent-call-1", + agent_id: "agent-0", event: { type: "ContentPart", payload: { type: "text", text: childAnswer } }, }, }), diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index a770d3a7..65ff60c2 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -15,5 +15,12 @@ } }, "include": ["src/**/*", "shared/**/*", "test/**/*"], - "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"] + "exclude": [ + "dist", + "node_modules", + "webview-ui", + "test/settings-store.test.ts", + "test/app-init.test.ts", + "test/event-handlers.test.ts" + ] } diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index aaee8432..e15348b8 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -72,7 +72,7 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { case "text": return ; case "tool_use": - return ; + return ; case "compaction": return ; case "steer": diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx index d46ef1c5..f7809666 100644 --- a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -14,12 +14,14 @@ import { IconSquareChevronRight, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; +import { getToolLabel, parseArgs } from "@/lib/tool-args"; import { FileLink, Markdown } from "./Markdown"; import { DisplayBlocks } from "./DisplayBlocks"; import { formatContentOutput } from "shared/legacy-sdk"; import { cleanSystemTags } from "shared/utils"; import { ThinkingBlock } from "./ThinkingBlock"; -import type { UIToolCall, UIStep, UIStepItem } from "@/stores/chat.store"; +import { WorkflowCard } from "./WorkflowCard"; +import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus } from "@/stores/chat.store"; import type { ToolResult, DisplayBlock, TodoBlock } from "shared/legacy-sdk"; type ToolResultValue = ToolResult["return_value"]; @@ -28,17 +30,7 @@ interface ToolRendererProps { call: UIToolCall; result?: ToolResultValue; subagentSteps?: UIStep[]; -} - -function parseArgs(args: string | null): Record { - if (!args) { - return {}; - } - try { - return JSON.parse(args); - } catch { - return { raw: args }; - } + subagentStatus?: Record; } function formatOutput(output: string | object | object[]): string { @@ -307,7 +299,7 @@ function GenericTool({ call, result }: ToolRendererProps) { ); } -function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { +export function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { if (item.type === "thinking") { return ; } @@ -315,7 +307,7 @@ function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { return ; } if (item.type === "tool_use") { - return ; + return ; } return null; } @@ -385,32 +377,24 @@ function TaskTool({ call, result, subagentSteps }: ToolRendererProps) { ); } -function getToolLabel(call: UIToolCall): string { - const args = parseArgs(call.arguments); - switch (call.name) { - case "Shell": - return (args.command as string) || "command"; - case "ReadFile": - return (args.path as string)?.split("/").pop() || "file"; - case "WriteFile": - return (args.path as string)?.split("/").pop() || "file"; - case "StrReplaceFile": - return (args.path as string)?.split("/").pop() || "file"; - case "Glob": - return (args.pattern as string) || "pattern"; - case "Task": - return (args.description as string) || "subagent task"; - case "SetTodoList": - return "Update Todos"; - default: - return ""; - } -} - -export function ToolCallCard({ call, result, subagentSteps }: ToolRendererProps) { +export function ToolCallCard({ call, result, subagentSteps, subagentStatus }: ToolRendererProps) { const [expanded, setExpanded] = useState(false); const status = !result ? "pending" : !result.is_error ? "success" : "error"; + // DynamicWorkflow batches render as per-agent lanes instead of the generic + // escaped-JSON card: its header/progress semantics don't fit the shared shell. + if (call.name === "DynamicWorkflow") { + return ( + } + /> + ); + } + const renderContent = () => { const props = { call, result, subagentSteps }; switch (call.name) { diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx new file mode 100644 index 00000000..c3321a56 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { deriveWorkflowLanes, maxLaneStepCount, type WorkflowLane } from "@/lib/workflow-lanes"; +import { getToolLabel, parseArgs } from "@/lib/tool-args"; +import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus } from "@/stores/chat.store"; +import type { ToolResult } from "shared/legacy-sdk"; + +interface WorkflowCardProps { + call: UIToolCall; + result?: ToolResult["return_value"]; + subagentSteps: UIStep[]; + subagentStatus: Record; + /** Injected by ToolRenderers: tool rendering is mutually recursive, and importing + * it here would close an import cycle that oxlint rejects. */ + renderStepItem: (item: UIStepItem) => ReactNode; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}` : `${seconds}s`; +} + +function laneLabel(lane: WorkflowLane): string { + if (lane.label) return lane.index !== undefined ? `${lane.label}/${lane.index}` : lane.label; + return lane.agentId.slice(0, 8); +} + +function laneMostRecentToolLabel(lane: WorkflowLane): string | null { + for (let i = lane.steps.length - 1; i >= 0; i--) { + const items = lane.steps[i].items; + for (let j = items.length - 1; j >= 0; j--) { + const item = items[j]; + if (item.type === "tool_use") return getToolLabel(item.call); + } + } + return null; +} + +function StatusDot({ status }: { status: WorkflowLane["status"] }) { + const color = + status === "running" ? "bg-primary" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground"; + return ; +} + +function LaneBar({ fraction, done }: { fraction: number; done: boolean }) { + return ( +
+
+
+ ); +} + +function LaneRow({ lane, maxSteps, renderStepItem }: { lane: WorkflowLane; maxSteps: number; renderStepItem: (item: UIStepItem) => ReactNode }) { + const [expanded, setExpanded] = useState(false); + const fraction = maxSteps > 0 ? lane.stepCount / maxSteps : 0; + const done = lane.status === "done"; + const queued = lane.status === "spawned" && lane.stepCount === 0; + const runningToolLabel = lane.status === "running" ? laneMostRecentToolLabel(lane) : null; + const duration = lane.startedAt !== undefined && lane.endedAt !== undefined ? formatDuration(lane.endedAt - lane.startedAt) : null; + + return ( +
+ + {lane.error &&
{lane.error}
} + {runningToolLabel &&
{runningToolLabel}
} + {expanded && ( +
+ {lane.steps.map((step) => ( +
+
Step {step.n}
+
+ {step.items.map((item, idx) => ( +
{renderStepItem(item)}
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +export function WorkflowCard({ call, result, subagentSteps, subagentStatus, renderStepItem }: WorkflowCardProps) { + const description = (parseArgs(call.arguments).description as string) || "Dynamic workflow"; + const lanes = useMemo( + () => deriveWorkflowLanes(subagentSteps, subagentStatus), + [subagentSteps, subagentStatus], + ); + const maxSteps = maxLaneStepCount(lanes); + const doneCount = lanes.filter((lane) => lane.status === "done").length; + const totalSteps = lanes.reduce((sum, lane) => sum + lane.stepCount, 0); + const showBatchBar = lanes.length > 1; + + return ( +
+
+
{description}
+
+ + {lanes.length} agent{lanes.length !== 1 ? "s" : ""} · {doneCount} done · {totalSteps} steps + + {showBatchBar && ( + <> +
+
0 ? Math.round((doneCount / lanes.length) * 100) : 0}%` }} /> +
+ + {doneCount}/{lanes.length} + + + )} +
+
+
+ {lanes.map((lane) => ( + + ))} +
+ {maxSteps > 0 &&
bar = steps relative to busiest agent ({maxSteps})
} + {result?.is_error &&
{typeof result.output === "string" ? result.output : "Workflow failed"}
} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/lib/tool-args.ts b/apps/vscode/webview-ui/src/lib/tool-args.ts new file mode 100644 index 00000000..4bee3d5a --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/tool-args.ts @@ -0,0 +1,34 @@ +import type { UIToolCall } from "@/stores/chat.store"; + +export function parseArgs(args: string | null): Record { + if (!args) { + return {}; + } + try { + return JSON.parse(args); + } catch { + return { raw: args }; + } +} + +export function getToolLabel(call: UIToolCall): string { + const args = parseArgs(call.arguments); + switch (call.name) { + case "Shell": + return (args.command as string) || "command"; + case "ReadFile": + return (args.path as string)?.split("/").pop() || "file"; + case "WriteFile": + return (args.path as string)?.split("/").pop() || "file"; + case "StrReplaceFile": + return (args.path as string)?.split("/").pop() || "file"; + case "Glob": + return (args.pattern as string) || "pattern"; + case "Task": + return (args.description as string) || "subagent task"; + case "SetTodoList": + return "Update Todos"; + default: + return ""; + } +} diff --git a/apps/vscode/webview-ui/src/lib/workflow-lanes.ts b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts new file mode 100644 index 00000000..59117a5f --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts @@ -0,0 +1,63 @@ +import type { UIStep, UISubagentStatus } from "@/stores/chat.store"; + +export interface WorkflowLane { + agentId: string; + label?: string; + index?: number; + steps: UIStep[]; + stepCount: number; + status: UISubagentStatus["status"]; + startedAt?: number; + endedAt?: number; + error?: string; + resultSummary?: string; +} + +/** Groups a DynamicWorkflow tool item's flat `subagent_steps` into per-agent lanes for WorkflowCard. */ +export function deriveWorkflowLanes(steps: UIStep[], statuses: Record): WorkflowLane[] { + const byAgent = new Map(); + + for (const step of steps) { + const agentId = step.agentId ?? ""; + const agentSteps = byAgent.get(agentId) ?? []; + agentSteps.push(step); + byAgent.set(agentId, agentSteps); + } + // A queued agent may have a status entry before it has produced a single step. + for (const agentId of Object.keys(statuses)) { + if (!byAgent.has(agentId)) { + byAgent.set(agentId, []); + } + } + + const lanes: WorkflowLane[] = []; + for (const [agentId, agentSteps] of byAgent) { + const status = statuses[agentId]; + const firstStep = agentSteps[0]; + lanes.push({ + agentId, + label: status?.label ?? firstStep?.agentLabel, + index: status?.index ?? firstStep?.agentIndex, + steps: agentSteps, + stepCount: agentSteps.length, + // `subagent.spawned` always precedes every other lifecycle event (see + // event-adapter.ts), so a lane with steps always has a status entry by + // the time this runs. This fallback is unreachable in practice; kept + // only because `statuses[agentId]` is not statically narrowed. + status: status?.status ?? "running", + startedAt: status?.startedAt, + endedAt: status?.endedAt, + error: status?.error, + resultSummary: status?.resultSummary, + }); + } + + lanes.sort((a, b) => (a.index ?? Number.MAX_SAFE_INTEGER) - (b.index ?? Number.MAX_SAFE_INTEGER)); + return lanes; +} + +/** The comparative-fill denominator: the busiest lane's step count. There is no + * per-agent step total, so progress bars read relative to each other, not absolute. */ +export function maxLaneStepCount(lanes: readonly WorkflowLane[]): number { + return lanes.reduce((max, lane) => Math.max(max, lane.stepCount), 0); +} diff --git a/apps/vscode/webview-ui/src/stores/chat.store.ts b/apps/vscode/webview-ui/src/stores/chat.store.ts index ae12e314..de63e4d9 100644 --- a/apps/vscode/webview-ui/src/stores/chat.store.ts +++ b/apps/vscode/webview-ui/src/stores/chat.store.ts @@ -22,6 +22,19 @@ export interface UIStep { n: number; items: UIStepItem[]; planMode?: boolean; + agentId?: string; + agentLabel?: string; + agentIndex?: number; +} + +export interface UISubagentStatus { + status: "spawned" | "running" | "done" | "failed" | "suspended"; + label?: string; + index?: number; + startedAt?: number; + endedAt?: number; + error?: string; + resultSummary?: string; } export interface InlineError { @@ -41,6 +54,7 @@ export type UIStepItem = call: UIToolCall; result?: ToolResult["return_value"]; subagent_steps?: UIStep[]; + subagent_status?: Record; }; export interface ChatMessage { diff --git a/apps/vscode/webview-ui/src/stores/event-handlers.ts b/apps/vscode/webview-ui/src/stores/event-handlers.ts index 526324ea..9596f40e 100644 --- a/apps/vscode/webview-ui/src/stores/event-handlers.ts +++ b/apps/vscode/webview-ui/src/stores/event-handlers.ts @@ -2,8 +2,8 @@ import { bridge } from "@/services"; import { useApprovalStore } from "./approval.store"; import { useSettingsStore } from "./settings.store"; import { isPreflightError, isUserInterrupt } from "shared/errors"; -import type { ChatMessage, UIStep, UIStepItem, ChatState, TokenUsage } from "./chat.store"; -import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk"; +import type { ChatMessage, UIStep, UIStepItem, UISubagentStatus, ChatState, TokenUsage } from "./chat.store"; +import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, SubagentStatusPayload, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk"; import type { UIStreamEvent, StreamError } from "shared/types"; type EventHandler = (draft: ChatState, payload: any) => void; @@ -63,11 +63,17 @@ function findToolUseItem(steps: UIStep[], toolId: string): (UIStepItem & { type: return null; } -function resolveSubagentTarget( - steps: UIStep[], - payload: SubagentEvent, -): { steps: UIStep[]; event: { type: string; payload: any }; toolItem: UIStepItem & { type: "tool_use" } } | null { - const { parent_tool_call_id, event } = payload; +interface SubagentTarget { + steps: UIStep[]; + event: { type: string; payload: any }; + toolItem: UIStepItem & { type: "tool_use" }; + agentId: string; + agentLabel?: string; + agentIndex?: number; +} + +function resolveSubagentTarget(steps: UIStep[], payload: SubagentEvent): SubagentTarget | null { + const { parent_tool_call_id, event, agent_id, agent_label, agent_index } = payload; // Nested SubagentEvent if (event.type === "SubagentEvent") { @@ -83,7 +89,40 @@ function resolveSubagentTarget( toolItem.subagent_steps = []; } - return { steps: toolItem.subagent_steps, event, toolItem }; + return { + steps: toolItem.subagent_steps, + event, + toolItem, + agentId: agent_id, + agentLabel: agent_label, + agentIndex: agent_index, + }; +} + +function applySubagentStatus( + toolItem: UIStepItem & { type: "tool_use" }, + payload: SubagentStatusPayload, +): void { + if (!toolItem.subagent_status) { + toolItem.subagent_status = {}; + } + const existing = toolItem.subagent_status[payload.agent_id]; + + const status: UISubagentStatus = { + status: payload.status, + label: payload.agent_label ?? existing?.label, + index: payload.agent_index ?? existing?.index, + // First transition to "running" stamps the start; the engine sends no timestamps + // of its own, so wall-clock lane duration is only as precise as Date.now() here. + startedAt: payload.status === "running" ? (existing?.startedAt ?? Date.now()) : existing?.startedAt, + endedAt: + payload.status === "done" || payload.status === "failed" || payload.status === "suspended" + ? (existing?.endedAt ?? Date.now()) + : existing?.endedAt, + error: payload.error ?? existing?.error, + resultSummary: payload.result_summary ?? existing?.resultSummary, + }; + toolItem.subagent_status[payload.agent_id] = status; } function finishAllTextItems(steps: UIStep[]): void { @@ -99,8 +138,29 @@ function finishAllTextItems(steps: UIStep[]): void { } } -function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any }, onText?: (text: string) => void): void { - const currentStep = steps.at(-1); +interface StepAgent { + id: string; + label?: string; + index?: number; +} + +function findLastStepForAgent(steps: UIStep[], agentId: string): UIStep | undefined { + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step?.agentId === agentId) return step; + } + return undefined; +} + +function applyEventToSteps( + steps: UIStep[], + event: { type: string; payload: any }, + onText?: (text: string) => void, + agent?: StepAgent, +): void { + // When an agent is known, target its own latest step rather than the array's + // tail — two agents can otherwise land text/tool calls in each other's step. + const currentStep = agent ? findLastStepForAgent(steps, agent.id) : steps.at(-1); const appendOrCreate = (type: "text" | "thinking", content: string): void => { if (!currentStep) { @@ -117,6 +177,9 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any const findLastToolUse = (): (UIStepItem & { type: "tool_use" }) | null => { for (let i = steps.length - 1; i >= 0; i--) { + if (agent && steps[i].agentId !== agent.id) { + continue; + } const items = steps[i].items; for (let j = items.length - 1; j >= 0; j--) { @@ -129,48 +192,42 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any return null; }; - const updateToolResult = (toolCallId: string, returnValue: ToolResult["return_value"]): boolean => { - for (const step of steps) { - for (const item of step.items) { - if (item.type === "tool_use") { - if (item.id === toolCallId) { - item.result = returnValue; - return true; - } - - if (item.subagent_steps && applyToolResult(item.subagent_steps, toolCallId, returnValue)) { - return true; - } - } - } - } - - return false; - }; - - const applyToolResult = (subSteps: UIStep[], toolCallId: string, returnValue: ToolResult["return_value"]): boolean => { + // Sets the result and returns the tool item, so the ToolResult handler below + // can reuse it (e.g. for the batch-abort freeze) without a second tree walk. + const setToolResult = ( + subSteps: UIStep[], + toolCallId: string, + returnValue: ToolResult["return_value"], + ): (UIStepItem & { type: "tool_use" }) | null => { for (const step of subSteps) { for (const item of step.items) { if (item.type === "tool_use") { if (item.id === toolCallId) { item.result = returnValue; - return true; + return item; } - if (item.subagent_steps && applyToolResult(item.subagent_steps, toolCallId, returnValue)) { - return true; + if (item.subagent_steps) { + const found = setToolResult(item.subagent_steps, toolCallId, returnValue); + if (found) return found; } } } } - return false; + return null; }; switch (event.type) { case "StepBegin": finishAllTextItems(steps); - steps.push({ n: event.payload.n, items: [] }); + steps.push({ + n: event.payload.n, + items: [], + agentId: agent?.id, + agentLabel: agent?.label, + agentIndex: agent?.index, + }); break; case "ContentPart": { @@ -224,7 +281,18 @@ function applyEventToSteps(steps: UIStep[], event: { type: string; payload: any case "ToolResult": { const result = event.payload as ToolResult; - updateToolResult(result.tool_call_id, result.return_value); + const toolItem = setToolResult(steps, result.tool_call_id, result.return_value); + + // Batch aborted: lanes still spawned/running when the parent result lands + // have no further lifecycle events coming, so freeze them as failed. + if (toolItem?.subagent_status) { + for (const status of Object.values(toolItem.subagent_status)) { + if (status.status === "spawned" || status.status === "running") { + status.status = "failed"; + status.endedAt = status.endedAt ?? Date.now(); + } + } + } const paths = extractDiffPaths(result.return_value.display); if (paths.length > 0) { @@ -486,8 +554,22 @@ const eventHandlers: Record = { return; } - if (target.steps.length === 0) { - target.steps.push({ n: 1, items: [] }); + if (target.event.type === "SubagentStatus") { + applySubagentStatus(target.toolItem, target.event.payload as SubagentStatusPayload); + return; + } + + // Seeds a step only for an agent whose first event arrives before its own + // StepBegin. Seeding on StepBegin too would leave every lane with a leading + // empty step and inflate its step count by one. + if (target.event.type !== "StepBegin" && !target.steps.some((s) => s.agentId === target.agentId)) { + target.steps.push({ + n: 1, + items: [], + agentId: target.agentId, + agentLabel: target.agentLabel, + agentIndex: target.agentIndex, + }); } // Nested Subagent Task End: accumulate token usage @@ -499,7 +581,26 @@ const eventHandlers: Record = { } } - applyEventToSteps(target.steps, target.event); + applyEventToSteps( + target.steps, + target.event, + undefined, + { id: target.agentId, label: target.agentLabel, index: target.agentIndex }, + ); + }, + + SubagentStatus: (draft, payload: SubagentStatusPayload) => { + const last = getLastAssistant(draft); + if (!last?.steps) { + return; + } + + const toolItem = findToolUseItem(last.steps, payload.parent_tool_call_id); + if (!toolItem) { + return; + } + + applySubagentStatus(toolItem, payload); }, ApprovalRequest: (_, payload: ApprovalRequestPayload) => { diff --git a/apps/vscode/webview-ui/tsconfig.json b/apps/vscode/webview-ui/tsconfig.json index 8ed30156..5574f36f 100644 --- a/apps/vscode/webview-ui/tsconfig.json +++ b/apps/vscode/webview-ui/tsconfig.json @@ -20,5 +20,5 @@ "shared/*": ["../shared/*"] } }, - "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts"] + "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts", "../test/event-handlers.test.ts"] } From 11aaa83727d1b7c70b3356631316b47203e5152f Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 11:23:50 -0400 Subject: [PATCH 02/36] feat(site): add npm downloads badge to hero Surface live monthly npm downloads for @pythoughts/pythinker-code in the hero, above the install command. Unify the badge accent across the site and README on the brand blue (#2b89ff). --- README.md | 2 +- apps/site/src/App.vue | 35 ++++++++++++++++++- .../src/components/LegacyDownloadsPopup.vue | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d402d85d..6896e936 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
[![npm version](https://img.shields.io/npm/v/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=CB3837&label=pythinker-code)](https://www.npmjs.com/package/@pythoughts/pythinker-code) -[![Downloads](https://img.shields.io/npm/dm/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=16a34a&label=downloads)](https://www.npmjs.com/package/@pythoughts/pythinker-code) +[![Downloads](https://img.shields.io/npm/dm/@pythoughts/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=2b89ff&label=downloads)](https://www.npmjs.com/package/@pythoughts/pythinker-code) [![Node.js](https://img.shields.io/badge/Node.js-26%2B-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-16a34a.svg?style=for-the-badge)](https://github.com/Pythoughts-labs/pythinker-code/blob/main/LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/Pythoughts-labs/pythinker-code/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/Pythoughts-labs/pythinker-code/actions/workflows/ci.yml?query=branch%3Amain) diff --git a/apps/site/src/App.vue b/apps/site/src/App.vue index ec267581..a0b42f02 100644 --- a/apps/site/src/App.vue +++ b/apps/site/src/App.vue @@ -227,6 +227,20 @@ onUnmounted(() => {

Pythinker Code

Think first, then code.

An open-source AI engineering agent for your terminal. It reads your repo, edits files, runs commands, and iterates until the job is done.

+ + npm downloads per month for @pythoughts/pythinker-code +
@@ -630,7 +644,7 @@ onUnmounted(() => { .hero-install { max-width: 720px; - margin: 36px auto 0; + margin: 10px auto 0; } .hero-download-milestone { @@ -639,6 +653,25 @@ onUnmounted(() => { justify-content: center; } +.hero-npm-badge { + display: inline-flex; + margin-top: 14px; + border-radius: var(--radius); + opacity: 0.85; + transition: opacity 0.2s ease; +} + +.hero-npm-badge:hover { + opacity: 1; +} + +/* ponytail: width:auto lets the badge grow as the download count does; the width attr only reserves space */ +.hero-npm-badge img { + display: block; + width: auto; + height: 20px; +} + .hero-caption { margin-top: 12px; color: var(--ink-subtle); diff --git a/apps/site/src/components/LegacyDownloadsPopup.vue b/apps/site/src/components/LegacyDownloadsPopup.vue index 3e560d1a..a1740150 100644 --- a/apps/site/src/components/LegacyDownloadsPopup.vue +++ b/apps/site/src/components/LegacyDownloadsPopup.vue @@ -25,7 +25,7 @@ const visible = ref(true); aria-label="View pythinker-code download statistics on Pepy" > PyPI download count for pythinker-code From 653612e7d1fbf3252e1b1ec7ff9b88444c928a0f Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 11:24:49 -0400 Subject: [PATCH 03/36] fix(vscode): address CodeRabbit findings on workflow lanes Pluralize the lane step count, add aria-expanded to the lane toggle, guard parseArgs against non-object JSON.parse results, merge duplicate tool-label cases, and gate the lane-failure sweep on the parent ToolResult's is_error flag so a successful batch result no longer marks still-running lanes as failed. --- apps/vscode/test/event-handlers.test.ts | 29 ++++++++++++++++--- .../src/components/WorkflowCard.tsx | 4 +-- apps/vscode/webview-ui/src/lib/tool-args.ts | 8 +++-- .../webview-ui/src/stores/event-handlers.ts | 2 +- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts index daebb7f5..4b2772dc 100644 --- a/apps/vscode/test/event-handlers.test.ts +++ b/apps/vscode/test/event-handlers.test.ts @@ -6,6 +6,9 @@ * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/event-handlers.test.ts */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChatStore } from "../webview-ui/src/stores/chat.store"; +import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; +import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; const boundary = vi.hoisted(() => ({ saveConfig: vi.fn(), @@ -28,10 +31,6 @@ vi.mock("@/components/ui/sonner", () => ({ toast: { error: boundary.toastError, warning: boundary.toastWarning }, })); -import { useChatStore } from "../webview-ui/src/stores/chat.store"; -import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; -import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; - beforeEach(() => { boundary.streamChat.mockReset(); boundary.abortChat.mockReset(); @@ -220,6 +219,28 @@ describe("Webview DynamicWorkflow per-agent lanes", () => { // Already-terminal lanes are left alone. expect(status["agentC"]!.status).toBe("done"); }); + + it("leaves spawned/running lanes alone when the parent ToolResult succeeds", () => { + startWorkflowTurn(); + + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" }, + }); + useChatStore.getState().processEvent({ + type: "SubagentStatus", + payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" }, + }); + + useChatStore.getState().processEvent({ + type: "ToolResult", + payload: { tool_call_id: "wf-1", return_value: { is_error: false, output: "ok", message: "", display: [] } }, + }); + + const status = workflowToolItem().subagent_status!; + expect(status["agentA"]!.status).toBe("running"); + expect(status["agentA"]!.endedAt).toBeUndefined(); + }); }); describe("workflow lane derivation", () => { diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx index c3321a56..5830f31d 100644 --- a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -63,12 +63,12 @@ function LaneRow({ lane, maxSteps, renderStepItem }: { lane: WorkflowLane; maxSt return (
- diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index e15348b8..80429899 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -33,7 +33,7 @@ function ThinkingIndicator() { return (
-
+
Pythinking @@ -45,9 +45,9 @@ function ThinkingIndicator() {
{speed > 0 && ( - - - {speed.toFixed(1)} t/s + + + {speed.toFixed(1)} t/s )}
diff --git a/apps/vscode/webview-ui/src/components/Markdown.tsx b/apps/vscode/webview-ui/src/components/Markdown.tsx index 12efe060..ab84e06d 100644 --- a/apps/vscode/webview-ui/src/components/Markdown.tsx +++ b/apps/vscode/webview-ui/src/components/Markdown.tsx @@ -253,7 +253,8 @@ export const Markdown = memo(function Markdown({ content, className, enableEnric if (isInline) { const showColor = enableEnrichment && hasColors(code); return ( - + // --brand reads as a distinct token without competing with body text. + {showColor ? : children} ); diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx index 7e7c0c5a..236d53fc 100644 --- a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx +++ b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx @@ -33,8 +33,8 @@ export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, d disabled={disabled || mode === "always"} className={cn( "flex items-center gap-0.5 justify-center h-6 min-w-6 px-1 rounded-md transition-all", - active ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", - !disabled && mode !== "always" && "cursor-pointer hover:bg-primary/25", + active ? "bg-blue-500/15 text-blue-500" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", + !disabled && mode !== "always" && "cursor-pointer hover:bg-blue-500/25", (disabled || mode === "always") && "cursor-default", )} > diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx index 5830f31d..9ee8b26b 100644 --- a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -41,22 +41,25 @@ function laneMostRecentToolLabel(lane: WorkflowLane): string | null { function StatusDot({ status }: { status: WorkflowLane["status"] }) { const color = - status === "running" ? "bg-primary" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground"; + status === "running" ? "bg-brand" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground"; return ; } function LaneBar({ fraction, done }: { fraction: number; done: boolean }) { return (
-
+
); } function LaneRow({ lane, maxSteps, renderStepItem }: { lane: WorkflowLane; maxSteps: number; renderStepItem: (item: UIStepItem) => ReactNode }) { const [expanded, setExpanded] = useState(false); - const fraction = maxSteps > 0 ? lane.stepCount / maxSteps : 0; const done = lane.status === "done"; + // A finished lane always reads full: the bar is relative to the busiest agent, + // so a lane that did fewer steps than the busiest one would otherwise show a + // gap after it completed. + const fraction = done ? 1 : maxSteps > 0 ? lane.stepCount / maxSteps : 0; const queued = lane.status === "spawned" && lane.stepCount === 0; const runningToolLabel = lane.status === "running" ? laneMostRecentToolLabel(lane) : null; const duration = lane.startedAt !== undefined && lane.endedAt !== undefined ? formatDuration(lane.endedAt - lane.startedAt) : null; diff --git a/apps/vscode/webview-ui/src/styles/index.css b/apps/vscode/webview-ui/src/styles/index.css index fc1b716e..9a4a2a96 100644 --- a/apps/vscode/webview-ui/src/styles/index.css +++ b/apps/vscode/webview-ui/src/styles/index.css @@ -14,20 +14,18 @@ --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); - /* Brand periwinkle, matching the CLI (lightColors.primary). Was - oklch(0.21) — a near-black that made every accent surface read as an - unstyled dark chip. */ - --primary: #4a5bc4; + --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); - /* Periwinkle, matching the CLI palette (lightColors.primary). Plain grey at - this size read as disabled rather than secondary. */ - --muted-foreground: #4a5bc4; + --muted-foreground: oklch(0.552 0.016 285.938); --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); + /* Muted periwinkle accent (CLI primary). Used for inline code and progress + fills — --primary is a near-black neutral and disappears against --muted. */ + --brand: #4a5bc4; /* CLI lightColors.success — the "on" colour for toggles. */ --success: #0e7a38; --success-foreground: oklch(1 0 0); @@ -43,20 +41,17 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.18 0.005 285.823); --popover-foreground: oklch(0.985 0 0); - /* Brand periwinkle, matching the CLI (darkColors.primary). The old - oklch(0.21) was DARKER than --input (oklch 0.274) and barely above the - background, so accent surfaces and "on" states disappeared in dark mode. */ - --primary: #bbc6ff; - --primary-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); - /* Periwinkle, matching the CLI palette (darkColors.primary). The previous - near-neutral grey sat too close to the background to read at 11px. */ - --muted-foreground: #bbc6ff; + --muted-foreground: oklch(0.705 0.015 286.067); --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + /* Muted periwinkle accent (CLI primary), dark variant. */ + --brand: #aab3e8; /* CLI darkColors.success — the "on" colour for toggles. */ --success: #4ec87e; --success-foreground: oklch(0.141 0.005 285.823); @@ -110,6 +105,7 @@ body { --color-destructive: var(--destructive); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-brand: var(--brand); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); From c1b096dbb2ad8e2e1ba24aa4460fc58e1a7c0c03 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 12:31:40 -0400 Subject: [PATCH 06/36] fix(vscode): make the session permission mode survive attach and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension modelled approval as a yolo/afk flag pair that it mapped onto the engine's permission mode on every change. Three things were wrong with it: - `withGlobalYoloMode` re-applied the `pythinker.yoloMode` setting on every attach and resume, so a `/yolo` a user typed was silently reverted the next time the session was opened. - `applyLegacyApproval` never wrote the flags back, so nothing was persisted for a resume to restore in the first place. - the two call sites that did try to persist called `session.updateMetadata`, which does not exist on the SDK session — the optional call quietly did nothing, and the tests passed because the fake implemented that same non-existent method. The flag pair is gone. A session now carries the engine's `PermissionMode` directly, persisted in session metadata (`vscode_permission_mode`, in the `custom` bag the SDK actually reads back) whenever it changes. The setting seeds sessions that have never recorded a mode and is applied to the live sessions when the user changes it — it no longer overrides on attach. `/yolo` and `/auto` now toggle between their mode and manual and accept `on`/`off`, matching the CLI. The one behaviour change: `/auto off` returns to manual instead of falling back to a remembered yolo, because a single mode has no second flag to fall back to. Sessions saved before this change have no stored mode and start from the setting; their old `vscode_legacy_approval` metadata is ignored. --- apps/vscode/package.json | 2 +- apps/vscode/src/PythinkerWebviewProvider.ts | 6 +- apps/vscode/src/extension.ts | 3 +- apps/vscode/src/handlers/config.handler.ts | 8 +- apps/vscode/src/handlers/slash-command.ts | 57 ++++++++---- apps/vscode/src/runtime/legacy-approval.ts | 52 ----------- apps/vscode/src/runtime/permission-mode.ts | 45 ++++++++++ apps/vscode/src/runtime/pythinker-runtime.ts | 89 +++++++++---------- apps/vscode/src/runtime/session-runtime.ts | 45 +++++----- apps/vscode/test/bridge-handler.test.ts | 5 +- .../pythinker-harness.integration.test.ts | 42 ++++++--- apps/vscode/test/pythinker-runtime.test.ts | 81 +++++++++-------- apps/vscode/test/session-runtime.test.ts | 46 +++++++--- apps/vscode/test/workspace-paths.test.ts | 2 +- .../src/components/SettingsDialog.tsx | 2 +- 15 files changed, 274 insertions(+), 211 deletions(-) delete mode 100644 apps/vscode/src/runtime/legacy-approval.ts create mode 100644 apps/vscode/src/runtime/permission-mode.ts diff --git a/apps/vscode/package.json b/apps/vscode/package.json index f3ba029b..49adb4ca 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -53,7 +53,7 @@ "pythinker.yoloMode": { "type": "boolean", "default": false, - "description": "Auto-approve regular tool calls; the agent may still ask questions" + "description": "Start new sessions in YOLO mode (auto-approve regular tool calls; the agent may still ask questions). Sessions keep the mode they were last left in; changing this applies to the open sessions too." }, "pythinker.autosave": { "type": "boolean", diff --git a/apps/vscode/src/PythinkerWebviewProvider.ts b/apps/vscode/src/PythinkerWebviewProvider.ts index d280333c..94090bf5 100644 --- a/apps/vscode/src/PythinkerWebviewProvider.ts +++ b/apps/vscode/src/PythinkerWebviewProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import type { PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; +import type { PermissionMode, PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; import { Events } from "../shared/bridge"; import { BridgeHandler } from "./bridge-handler"; @@ -145,8 +145,8 @@ export class PythinkerWebviewProvider implements vscode.WebviewViewProvider { return this.bridgeHandler.getBaselineContent(sessionId, filePath); } - async setYoloModeForActiveSessions(enabled: boolean): Promise { - await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled); + async setPermissionModeForActiveSessions(mode: PermissionMode): Promise { + await this.bridgeHandler.runtime.setPermissionModeForActiveSessions(mode); } private getHtml(webviewId: string, webview: vscode.Webview): string { diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 944b4c30..088b564d 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { Events } from "../shared/bridge"; import { PythinkerWebviewProvider } from "./PythinkerWebviewProvider"; import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { defaultPermissionMode } from "./runtime/permission-mode"; import { updateLoginContext } from "./utils/context"; let outputChannel: vscode.OutputChannel | undefined; @@ -48,7 +49,7 @@ export async function activate(context: vscode.ExtensionContext): Promise }); if (changedKeys.includes("yoloMode")) { void provider - ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode) + ?.setPermissionModeForActiveSessions(defaultPermissionMode(VSCodeSettings.yoloMode)) .catch((error) => logError("Unable to update session permission", error)); } }), vscode.window.registerWebviewViewProvider("pythinker.webview", provider, { diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 116fd82a..4a16e652 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -16,11 +16,15 @@ const SLASH_COMMANDS: SlashCommandInfo[] = [ { name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" }, { name: "compact", aliases: [], description: "Compact the conversation context" }, { name: "clear", aliases: ["reset"], description: "Clear the context" }, - { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions)" }, + { + name: "yolo", + aliases: [], + description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions). Usage: /yolo [on|off]", + }, { name: "auto", aliases: ["afk"], - description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)", + description: "Toggle Auto mode (fully autonomous; the agent will not ask questions). Usage: /auto [on|off]", }, { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" }, { diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index 8fefcc55..917bf0fc 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -4,6 +4,8 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import * as vscode from "vscode"; +import type { PermissionMode } from "@pythoughts/pythinker-code-sdk"; + import type { SessionRuntime } from "../runtime/session-runtime"; import { buildExportMarkdown, @@ -84,11 +86,11 @@ export async function runHostSlashCommand( emit("The context has been cleared."); break; case "yolo": - await toggleLegacyPermission(runtime, "yolo", emit); + await runPermissionCommand(runtime, "yolo", command.args, emit); break; case "auto": case "afk": - await toggleLegacyPermission(runtime, "afk", emit); + await runPermissionCommand(runtime, "auto", command.args, emit); break; case "plan": await runPlanCommand(runtime, command.args, emit); @@ -114,26 +116,49 @@ export async function runHostSlashCommand( } } -async function toggleLegacyPermission( +const PERMISSION_MODE_ENABLED_MESSAGE = { + yolo: "You only live once! Tool actions will be auto-approved; the agent may still ask questions.", + auto: "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved.", +} as const; + +const PERMISSION_MODE_DISABLED_MESSAGE = { + yolo: "You only die once! Actions will require approval.", + auto: "Auto mode disabled. You are back at the keyboard.", +} as const; + +/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */ +async function runPermissionCommand( runtime: SessionRuntime, - kind: "yolo" | "afk", + mode: "yolo" | "auto", + args: string, emit: (text: string) => void, ): Promise { - const flags = await runtime.toggleLegacyApproval(kind); + const subcommand = args.trim().toLowerCase(); + const requested = + subcommand === "on" ? mode : subcommand === "off" ? "manual" : undefined; - if (kind === "yolo") { - emit(flags.yolo - ? "You only live once! Tool actions will be auto-approved; the agent may still ask questions." - : flags.afk - ? "Yolo disabled, but Auto is still on — tool calls remain auto-approved." - : "You only die once! Actions will require approval."); + if (requested !== undefined && runtime.permissionMode === requested) { + emit(requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`); return; } - emit(flags.afk - ? "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved." - : flags.yolo - ? "Auto mode disabled. You are back at the keyboard. Yolo is still on." - : "Auto mode disabled. You are back at the keyboard."); + + let current: PermissionMode; + if (requested === undefined) { + current = await runtime.togglePermissionMode(mode); + } else { + await runtime.setPermissionMode(requested); + current = requested; + } + + emit( + current === mode + ? PERMISSION_MODE_ENABLED_MESSAGE[mode] + : PERMISSION_MODE_DISABLED_MESSAGE[mode], + ); +} + +function label(mode: "yolo" | "auto"): string { + return mode === "yolo" ? "YOLO mode" : "Auto mode"; } async function runPlanCommand( diff --git a/apps/vscode/src/runtime/legacy-approval.ts b/apps/vscode/src/runtime/legacy-approval.ts deleted file mode 100644 index ab3fa850..00000000 --- a/apps/vscode/src/runtime/legacy-approval.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { JsonObject, PermissionMode } from "@pythoughts/pythinker-code-sdk"; - -export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval"; - -export interface LegacyApprovalFlags { - readonly yolo: boolean; - readonly afk: boolean; -} - -export function readLegacyApprovalFlags( - metadata: Readonly> | undefined, -): LegacyApprovalFlags | undefined { - const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY]; - return parseLegacyApprovalFlags(value); -} - -function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - const yolo = Reflect.get(value, "yolo"); - const afk = Reflect.get(value, "afk"); - if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined; - return { - yolo: typeof yolo === "boolean" ? yolo : false, - afk: typeof afk === "boolean" ? afk : false, - }; -} - -export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject { - return { - [LEGACY_APPROVAL_METADATA_KEY]: { - yolo: flags.yolo, - afk: flags.afk, - }, - }; -} - -export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode { - if (flags.afk) return "auto"; - return flags.yolo ? "yolo" : "manual"; -} - -/** - * The global `pythinker.yoloMode` setting is authoritative whenever a session - * attaches to the runtime; afk stays per-session because it has no global - * setting counterpart. - */ -export function withGlobalYoloMode( - flags: LegacyApprovalFlags, - yoloMode: boolean, -): LegacyApprovalFlags { - return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk }; -} diff --git a/apps/vscode/src/runtime/permission-mode.ts b/apps/vscode/src/runtime/permission-mode.ts new file mode 100644 index 00000000..022691dd --- /dev/null +++ b/apps/vscode/src/runtime/permission-mode.ts @@ -0,0 +1,45 @@ +import type { JsonObject, PermissionMode, Session } from "@pythoughts/pythinker-code-sdk"; + +/** + * The engine keeps the permission mode in memory only, so a resumed session + * would always come back as `manual`. The extension persists the mode the user + * chose in session metadata and restores it on attach. + */ +export const SESSION_PERMISSION_MODE_KEY = "vscode_permission_mode"; + +const PERMISSION_MODES: readonly PermissionMode[] = new Set(["manual", "auto", "yolo"]); + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === "string" && PERMISSION_MODES.has(value as PermissionMode); +} + +export function readPermissionMode( + metadata: Readonly> | undefined, +): PermissionMode | undefined { + const value = metadata?.[SESSION_PERMISSION_MODE_KEY]; + return isPermissionMode(value) ? value : undefined; +} + +export function permissionModeMetadata(mode: PermissionMode): JsonObject { + return { [SESSION_PERMISSION_MODE_KEY]: mode }; +} + +/** + * The session-meta patch is a shallow merge, and the caller-owned bag it lands + * in is `custom` — surfaced as `summary.metadata` on the read side. The whole + * bag is rewritten so the keys it already holds survive the patch. + */ +export async function persistPermissionMode( + session: Session, + mode: PermissionMode, +): Promise { + const meta = await session.getSessionMetadata(); + await session.updateSessionMetadata({ + custom: { ...meta.custom, ...permissionModeMetadata(mode) }, + }); +} + +/** The `pythinker.yoloMode` setting seeds new sessions; it never overrides a stored mode. */ +export function defaultPermissionMode(yoloModeSetting: boolean): PermissionMode { + return yoloModeSetting ? "yolo" : "manual"; +} diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts index 29443438..04340115 100644 --- a/apps/vscode/src/runtime/pythinker-runtime.ts +++ b/apps/vscode/src/runtime/pythinker-runtime.ts @@ -1,5 +1,6 @@ import { createPythinkerHarness, + type PermissionMode, type PythinkerHarness, type Session, type SessionSummary, @@ -7,12 +8,11 @@ import { import type { RuntimeBroadcast } from "./session-runtime"; import { - corePermissionForLegacyApproval, - legacyApprovalMetadata, - readLegacyApprovalFlags, - withGlobalYoloMode, - type LegacyApprovalFlags, -} from "./legacy-approval"; + defaultPermissionMode, + permissionModeMetadata, + persistPermissionMode, + readPermissionMode, +} from "./permission-mode"; import { SessionRuntime } from "./session-runtime"; import { areSameFsPath } from "../utils/fs-path"; @@ -84,7 +84,7 @@ export class PythinkerRuntime { requestedId === current.id && areSameFsPath(current.session.workDir, options.workDir) ) { - await applySessionSettings(current.session, options, current.legacyApprovalFlags); + await applySessionPermission(current.session, current.permissionMode); await current.announceStatus(options.webviewId); return current; } @@ -92,31 +92,25 @@ export class PythinkerRuntime { let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId); if (runtime !== undefined) { assertSessionWorkDir(runtime.session, options.workDir); - await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags); + await applySessionPermission(runtime.session, runtime.permissionMode); await this.detachView(options.webviewId); } else { - const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false }; + const seedMode = defaultPermissionMode(options.yoloMode); const session = requestedId === undefined ? await this.harness.createSession({ workDir: options.workDir, model: options.model || undefined, thinking: normalizeEffort(options.effort), - permission: corePermissionForLegacyApproval(defaultApproval), - metadata: legacyApprovalMetadata(defaultApproval), + permission: seedMode, + metadata: permissionModeMetadata(seedMode), }) : await this.harness.resumeSession({ id: requestedId }); try { assertSessionWorkDir(session, options.workDir); - const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); - const restoredApproval = storedApproval ?? defaultApproval; - const approval = withGlobalYoloMode(restoredApproval, options.yoloMode); - if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { - await (session as any).updateMetadata?.(legacyApprovalMetadata(approval)); - } - await applySessionSettings(session, options, approval); + const mode = await restorePermissionMode(session, seedMode); await this.detachView(options.webviewId); - runtime = this.wrapSession(session, approval); + runtime = this.wrapSession(session, mode); } catch (error) { await session.close().catch((closeError: unknown) => { this.log("Failed to close a rejected session", closeError); @@ -134,7 +128,7 @@ export class PythinkerRuntime { async attachResumedSession( webviewId: string, session: Session, - defaultYoloMode = false, + yoloModeSetting = false, ): Promise { const existing = this.sessions.get(session.id); if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) { @@ -146,16 +140,8 @@ export class PythinkerRuntime { let runtime = existing ?? this.sessions.get(session.id); if (runtime === undefined) { try { - const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); - const restoredApproval = storedApproval ?? { yolo: defaultYoloMode, afk: false }; - const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode); - if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { - await (session as any).updateMetadata?.(legacyApprovalMetadata(approval)); - } - const status = await session.getStatus(); - const permission = corePermissionForLegacyApproval(approval); - if (status.permission !== permission) await session.setPermission(permission); - runtime = this.wrapSession(session, approval); + const mode = await restorePermissionMode(session, defaultPermissionMode(yoloModeSetting)); + runtime = this.wrapSession(session, mode); } catch (error) { await session.close().catch((closeError: unknown) => { this.log("Failed to close a rejected session", closeError); @@ -200,9 +186,13 @@ export class PythinkerRuntime { await ((this.harness as any).deleteSession?.(id) ?? Promise.resolve()); } - async setYoloModeForActiveSessions(enabled: boolean): Promise { + /** + * Applies an explicit settings change to the live sessions. Attach and resume + * never do this — they restore whatever mode the session was left in. + */ + async setPermissionModeForActiveSessions(mode: PermissionMode): Promise { await Promise.all( - [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)), + [...this.sessions.values()].map((session) => session.setPermissionMode(mode)), ); } @@ -215,10 +205,10 @@ export class PythinkerRuntime { await this.harness.close(); } - private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime { + private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime { const runtime = new SessionRuntime({ session, - legacyApproval, + permissionMode, broadcast: this.broadcast, captureBaseline: this.captureBaseline, log: this.log, @@ -232,24 +222,31 @@ export class PythinkerRuntime { } } -async function applySessionSettings( +/** + * The engine forgets the permission mode when a session is resumed, so the + * stored mode is authoritative and the seed only covers sessions that have + * never recorded one. + */ +async function restorePermissionMode( session: Session, - options: OpenSessionOptions, - legacyApproval: LegacyApprovalFlags, -): Promise { - const status = await session.getStatus(); - const permission = corePermissionForLegacyApproval(legacyApproval); - if (status.permission !== permission) { - await session.setPermission(permission); + seedMode: PermissionMode, +): Promise { + const storedMode = readPermissionMode(session.summary?.metadata); + const mode = storedMode ?? seedMode; + if (storedMode === undefined) { + await persistPermissionMode(session, mode); } + await applySessionPermission(session, mode); + return mode; } -export function normalizeEffort(effort: string): string { - return effort.trim() || "off"; +async function applySessionPermission(session: Session, mode: PermissionMode): Promise { + const status = await session.getStatus(); + if (status.permission !== mode) await session.setPermission(mode); } -function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean { - return a.yolo !== b.yolo || a.afk !== b.afk; +export function normalizeEffort(effort: string): string { + return effort.trim() || "off"; } function assertSessionWorkDir(session: Pick, expectedWorkDir: string): void { diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index f12d6769..cf16e44c 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -2,6 +2,7 @@ import { isPythinkerError, type ContentPart as SdkContentPart, type Event, + type PermissionMode, type PromptInput, type Session, type SessionSummary, @@ -17,14 +18,14 @@ import { type EventAdapterState, type TurnTerminalMetadata, } from "./event-adapter"; -import { corePermissionForLegacyApproval, type LegacyApprovalFlags } from "./legacy-approval"; +import { persistPermissionMode } from "./permission-mode"; import { ReverseRpcController } from "./reverse-rpc"; export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void; export interface SessionRuntimeOptions { readonly session: Session; - readonly legacyApproval: LegacyApprovalFlags; + readonly permissionMode: PermissionMode; readonly broadcast: RuntimeBroadcast; readonly captureBaseline: ( session: Pick, @@ -83,7 +84,7 @@ export class SessionRuntime { private exclusiveActionActive = false; private readonly terminalKeys = new Set(); private suppressedError: SuppressedError | undefined; - private legacyApproval: LegacyApprovalFlags; + private currentPermissionMode: PermissionMode; private closed = false; constructor(options: SessionRuntimeOptions) { @@ -91,13 +92,13 @@ export class SessionRuntime { this.broadcast = options.broadcast; this.captureBaseline = options.captureBaseline; this.log = options.log; - this.legacyApproval = options.legacyApproval; + this.currentPermissionMode = options.permissionMode; this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event)); // Forward every approval request to the user. The engine permission mode - // (mapped from the legacy flags) already auto-approves what yolo/auto - // allow internally; anything that reaches this handler is an exception - // (sensitive file, plan review, ask rule) the user must decide on. + // already auto-approves what yolo/auto allow internally; anything that + // reaches this handler is an exception (sensitive file, plan review, ask + // rule) the user must decide on. this.session.setApprovalHandler((request) => this.reverseRpc.requestApproval(request)); this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request)); this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event)); @@ -119,19 +120,24 @@ export class SessionRuntime { return this.hasActiveWork || this.exclusiveActionActive; } - get legacyApprovalFlags(): LegacyApprovalFlags { - return this.legacyApproval; + get permissionMode(): PermissionMode { + return this.currentPermissionMode; } - async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise { - const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] }; - await this.applyLegacyApproval(next); + /** Toggles between `mode` and `manual`, and returns the mode now in effect. */ + async togglePermissionMode(mode: Exclude): Promise { + const next = this.currentPermissionMode === mode ? "manual" : mode; + await this.setPermissionMode(next); return next; } - async setLegacyYoloMode(enabled: boolean): Promise { - if (this.legacyApproval.yolo === enabled) return; - await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled }); + async setPermissionMode(mode: PermissionMode): Promise { + if (this.currentPermissionMode === mode) return; + this.ensureOpen(); + const status = await this.session.getStatus(); + if (status.permission !== mode) await this.session.setPermission(mode); + await persistPermissionMode(this.session, mode); + this.currentPermissionMode = mode; } subscribe(webviewId: string): void { @@ -413,15 +419,6 @@ export class SessionRuntime { this.webviewIds.clear(); } - private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise { - this.ensureOpen(); - const permission = corePermissionForLegacyApproval(flags); - const status = await this.session.getStatus(); - const permissionChanged = status.permission !== permission; - if (permissionChanged) await this.session.setPermission(permission); - this.legacyApproval = flags; - } - private onSdkEvent(event: Event): void { if (this.closed) return; diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 648662c4..20f59d70 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -521,7 +521,7 @@ function createResumedSession(id: string, workDir: string) { sessionDir: join("/private/pythinker/sessions", id), createdAt: 1, updatedAt: 2, - metadata: { vscode_legacy_approval: { yolo: false, afk: false } }, + metadata: { vscode_permission_mode: "manual" }, }; return { id, @@ -559,7 +559,8 @@ function createResumedSession(id: string, workDir: string) { }), getStatus: async () => ({ permission: "manual" }), setPermission: async () => undefined, - updateMetadata: async () => undefined, + getSessionMetadata: async () => ({ custom: {} }), + updateSessionMetadata: async () => undefined, setApprovalHandler: () => undefined, setQuestionHandler: () => undefined, onEvent: () => () => undefined, diff --git a/apps/vscode/test/pythinker-harness.integration.test.ts b/apps/vscode/test/pythinker-harness.integration.test.ts index 5d1ddafd..0a6b5be4 100644 --- a/apps/vscode/test/pythinker-harness.integration.test.ts +++ b/apps/vscode/test/pythinker-harness.integration.test.ts @@ -1155,37 +1155,55 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) }); }); - it("keeps /yolo and /afk independent when they are combined", async () => { + it("moves between the permission modes as /yolo and /auto are used", async () => { const rig = await createRuntimeRig(); const runtime = await openRuntimeSession(rig); await runSlash(runtime, "/yolo"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + expect(runtime.permissionMode).toBe("yolo"); await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); await runSlash(runtime, "/afk"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + expect(runtime.permissionMode).toBe("auto"); await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); await runSlash(runtime, "/afk"); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); - await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + expect(runtime.permissionMode).toBe("manual"); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "manual" }); }); - it("applies the global yolo setting when a closed VS Code session reopens", async () => { + it("accepts on and off arguments for the permission commands", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await runSlash(runtime, "/yolo on"); + expect(runtime.permissionMode).toBe("yolo"); + + await runSlash(runtime, "/yolo on"); + expect(runtime.permissionMode).toBe("yolo"); + + await runSlash(runtime, "/yolo off"); + expect(runtime.permissionMode).toBe("manual"); + }); + + it("keeps a /yolo session in yolo when it reopens with the setting off", async () => { const rig = await createRuntimeRig(); const first = await openRuntimeSession(rig); await runSlash(first, "/yolo"); await rig.runtime.detachView("view-1"); const reopened = await openRuntimeSession(rig, first.id); - expect(reopened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); - await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "manual" }); - await rig.runtime.detachView("view-1"); - const yoloReopened = await openRuntimeSession(rig, first.id, true); - expect(yoloReopened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); - await expect(yoloReopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + expect(reopened.permissionMode).toBe("yolo"); + await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("seeds a session that never chose a mode from the global yolo setting", async () => { + const rig = await createRuntimeRig(); + const first = await openRuntimeSession(rig, undefined, true); + + expect(first.permissionMode).toBe("yolo"); + await expect(first.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); }); it("exports current context as Markdown under the workspace", async () => { diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index 01505f90..30567e79 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -113,9 +113,12 @@ function createFakeSession( setPermissions.push(permission); status = { ...status, permission }; }, - async updateMetadata(patch: JsonObject) { - metadataUpdates.push(patch); - summary = { ...summary, metadata: { ...summary.metadata, ...patch } }; + async getSessionMetadata() { + return { custom: summary.metadata }; + }, + async updateSessionMetadata(patch: { custom?: JsonObject }) { + metadataUpdates.push(patch.custom ?? {}); + summary = { ...summary, metadata: patch.custom }; }, async close() { closes += 1; @@ -261,7 +264,7 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { model: "kimi-k2", thinking: "high", permission: "yolo", - metadata: { vscode_legacy_approval: { yolo: true, afk: false } }, + metadata: { vscode_permission_mode: "yolo" }, }, ]); expect(opened.subscribers).toEqual(["view-1"]); @@ -396,91 +399,95 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { }); }); - it("uses the yolo setting as the initial value for an unmarked resumed session", async () => { + it("seeds an unmarked resumed session from the yolo setting", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: true, afk: false } }, - ]); + expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]); }); - it("lets the global yolo setting override a persisted off flag on resume", async () => { + it("keeps a stored yolo mode when the global setting is off", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: false } }, + { vscode_permission_mode: "yolo" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); expect(session.setPermissions).toEqual(["yolo"]); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: true, afk: false } }, - ]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + expect(session.metadataUpdates).toEqual([]); + expect(opened.permissionMode).toBe("yolo"); }); - it("lets the global yolo setting disable a persisted session yolo flag on resume", async () => { + it("keeps a stored manual mode when the global setting is on", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", - { permission: "yolo" }, - { vscode_legacy_approval: { yolo: true, afk: false } }, + { permission: "manual" }, + { vscode_permission_mode: "manual" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.setPermissions).toEqual(["manual"]); - expect(session.metadataUpdates).toEqual([ - { vscode_legacy_approval: { yolo: false, afk: false } }, - ]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + expect(session.setPermissions).toEqual([]); + expect(session.metadataUpdates).toEqual([]); + expect(opened.permissionMode).toBe("manual"); }); - it("keeps the persisted afk flag while applying the global yolo setting on resume", async () => { + it("restores a stored auto mode", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: true } }, + { vscode_permission_mode: "auto" }, ); - const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); expect(session.setPermissions).toEqual(["auto"]); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + expect(opened.permissionMode).toBe("auto"); }); - it("restores persisted afk with core auto permission", async () => { + it("ignores an unrecognised stored mode and falls back to the setting", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession( "saved-1", "/workspace", { permission: "manual" }, - { vscode_legacy_approval: { yolo: false, afk: true } }, + { vscode_permission_mode: "nonsense" }, ); - await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); - expect(session.setPermissions).toEqual(["auto"]); + expect(opened.permissionMode).toBe("yolo"); + expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]); }); - it("changes the setting-backed yolo flag without clearing session afk", async () => { + it("applies an explicit settings change to the live sessions", async () => { const { runtime } = createRuntime(); const opened = await runtime.openSession(openOptions()); - await opened.toggleLegacyApproval("afk"); - await runtime.setYoloModeForActiveSessions(true); + await runtime.setPermissionModeForActiveSessions("yolo"); + + expect(opened.permissionMode).toBe("yolo"); + await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("persists a mode change so the next attach restores it", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + + await opened.setPermissionMode("yolo"); - expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); - await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); + expect(session.metadataUpdates.at(-1)).toEqual({ vscode_permission_mode: "yolo" }); }); it("keeps a shared session open when one of its Webviews detaches", async () => { diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts index 3c476553..deec905e 100644 --- a/apps/vscode/test/session-runtime.test.ts +++ b/apps/vscode/test/session-runtime.test.ts @@ -20,7 +20,6 @@ import type { import { describe, expect, it } from "vitest"; import { Events } from "../shared/bridge"; -import type { LegacyApprovalFlags } from "../src/runtime/legacy-approval"; import { SessionRuntime } from "../src/runtime/session-runtime"; interface BroadcastRecord { @@ -53,7 +52,7 @@ interface FakeSessionBoundary { requestQuestion(request: QuestionRequest): Promise>>; } -const DEFAULT_LEGACY_APPROVAL: LegacyApprovalFlags = { yolo: false, afk: false }; +const DEFAULT_PERMISSION_MODE: PermissionMode = "manual"; function createFakeSession(): FakeSessionBoundary { const listeners = new Set<(event: Event) => void>(); @@ -129,13 +128,16 @@ function createFakeSession(): FakeSessionBoundary { permission = mode; setPermissions.push(mode); }, - async updateMetadata(patch: JsonObject) { + async getSessionMetadata() { + return { custom: summary.metadata }; + }, + async updateSessionMetadata(patch: { custom?: JsonObject }) { if (nextMetadataError !== undefined) { const error = nextMetadataError; nextMetadataError = undefined; throw error; } - metadataUpdates.push(patch); + metadataUpdates.push(patch.custom ?? {}); }, async close() { closes += 1; @@ -173,13 +175,13 @@ function createFakeSession(): FakeSessionBoundary { }; } -function createRuntime(legacyApproval = DEFAULT_LEGACY_APPROVAL) { +function createRuntime(permissionMode = DEFAULT_PERMISSION_MODE) { const sdk = createFakeSession(); const broadcasts: BroadcastRecord[] = []; const baselines: BaselineRecord[] = []; const runtime = new SessionRuntime({ session: sdk.session, - legacyApproval, + permissionMode, broadcast: (event, data, webviewId) => broadcasts.push({ event, data, webviewId }), captureBaseline: (session, filePath, webviewIds) => { baselines.push({ session, filePath, webviewIds }); @@ -570,8 +572,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual(expected); }); - it("forwards SDK approval requests to the Webview in legacy yolo mode", async () => { - const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + it("forwards SDK approval requests to the Webview in yolo mode", async () => { + const { runtime, sdk, broadcasts } = createRuntime("yolo"); const pending = sdk.requestApproval({ toolCallId: "tool-yolo", toolName: "Bash", @@ -590,13 +592,31 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual({ decision: "approved" }); }); - it("switches core permission when a legacy approval flag is toggled", async () => { + it("switches core permission when the mode is toggled on", async () => { const { runtime, sdk } = createRuntime(); - await runtime.toggleLegacyApproval("afk"); + await expect(runtime.togglePermissionMode("auto")).resolves.toBe("auto"); expect(sdk.setPermissions).toEqual(["auto"]); - expect(runtime.legacyApprovalFlags).toEqual({ yolo: false, afk: true }); + expect(runtime.permissionMode).toBe("auto"); + }); + + it("returns to manual when the same mode is toggled again", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.togglePermissionMode("auto"); + await expect(runtime.togglePermissionMode("auto")).resolves.toBe("manual"); + + expect(sdk.setPermissions).toEqual(["auto", "manual"]); + expect(runtime.permissionMode).toBe("manual"); + }); + + it("persists each mode change into the session metadata", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.setPermissionMode("yolo"); + + expect(sdk.metadataUpdates.at(-1)).toMatchObject({ vscode_permission_mode: "yolo" }); }); it("resolves an SDK question when the Webview submits answers", async () => { @@ -624,8 +644,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () await expect(pending).resolves.toEqual({ answers: { "Choose a target": "Tests" } }); }); - it("keeps SDK questions interactive in legacy yolo mode", async () => { - const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + it("keeps SDK questions interactive in yolo mode", async () => { + const { runtime, sdk, broadcasts } = createRuntime("yolo"); const pending = sdk.requestQuestion({ toolCallId: "question-yolo", questions: [ diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index 4cdbcd8b..f1582b7d 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -305,7 +305,7 @@ describe("Webview workspace paths (selected-directory containment)", () => { } as unknown as Session; const runtime = new SessionRuntime({ session, - legacyApproval: { yolo: false, afk: false }, + permissionMode: "manual", broadcast: vi.fn(), captureBaseline: (summary, filePath, webviewIds) => { bridge.captureFileBaseline(summary, filePath, webviewIds); diff --git a/apps/vscode/webview-ui/src/components/SettingsDialog.tsx b/apps/vscode/webview-ui/src/components/SettingsDialog.tsx index e5f6c85a..96b27b1b 100644 --- a/apps/vscode/webview-ui/src/components/SettingsDialog.tsx +++ b/apps/vscode/webview-ui/src/components/SettingsDialog.tsx @@ -17,7 +17,7 @@ const SETTING_ROWS: SettingRow[] = [ { key: "yoloMode", label: "YOLO mode", - description: "Auto-approve regular tool calls; the agent may still ask questions.", + description: "Auto-approve regular tool calls; the agent may still ask questions. Also starts new sessions in this mode.", }, { key: "autosave", From 81a7d39a16af01520d1a0eaadea5422a719953f5 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 12:32:24 -0400 Subject: [PATCH 07/36] fix(vscode): type the permission-mode set after the lint autofix --- apps/vscode/src/runtime/permission-mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/runtime/permission-mode.ts b/apps/vscode/src/runtime/permission-mode.ts index 022691dd..1b142b44 100644 --- a/apps/vscode/src/runtime/permission-mode.ts +++ b/apps/vscode/src/runtime/permission-mode.ts @@ -7,7 +7,7 @@ import type { JsonObject, PermissionMode, Session } from "@pythoughts/pythinker- */ export const SESSION_PERMISSION_MODE_KEY = "vscode_permission_mode"; -const PERMISSION_MODES: readonly PermissionMode[] = new Set(["manual", "auto", "yolo"]); +const PERMISSION_MODES: ReadonlySet = new Set(["manual", "auto", "yolo"]); export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === "string" && PERMISSION_MODES.has(value as PermissionMode); From cbc5171416f45f0c2fb0c73f7bbc1bf4fd95532c Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 12:48:29 -0400 Subject: [PATCH 08/36] feat(vscode): manage model providers from the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension already read the same ~/.pythinker-code/config.toml the CLI does, so providers added in the terminal appeared in the model picker — but there was no way to add one without leaving the editor. A Providers modal, reached from the gear menu, lists what config.toml defines and adds a provider from the models.dev catalog: search, paste a key or point at an environment variable, optionally pick a default model. Removal goes through the same harness call the CLI uses. Keys are written to config.toml exactly as the CLI writes them, so a provider added here works in the terminal too; the key itself never crosses into the Webview, which only learns whether one is configured and where it comes from. The import rules — dropping stale aliases before a re-import, and restoring the previous defaults only while they still resolve against the refreshed catalog — now live in one SDK routine that both the CLI and the extension call, instead of only in the CLI. --- .changeset/shared-catalog-provider-import.md | 6 + apps/pythinker-code/src/cli/sub/provider.ts | 81 +--- apps/vscode/shared/bridge.ts | 16 + apps/vscode/shared/types.ts | 38 ++ apps/vscode/src/handlers/index.ts | 2 + apps/vscode/src/handlers/provider.handler.ts | 130 ++++++ apps/vscode/test/bridge-handler.test.ts | 97 ++++ apps/vscode/webview-ui/src/App.tsx | 2 + .../webview-ui/src/components/ActionMenu.tsx | 13 +- .../src/components/ProvidersModal.tsx | 423 ++++++++++++++++++ apps/vscode/webview-ui/src/services/bridge.ts | 21 + .../webview-ui/src/stores/settings.store.ts | 4 + packages/node-sdk/src/catalog.ts | 108 +++++ packages/node-sdk/src/index.ts | 5 + packages/node-sdk/test/catalog.test.ts | 118 +++++ 15 files changed, 994 insertions(+), 70 deletions(-) create mode 100644 .changeset/shared-catalog-provider-import.md create mode 100644 apps/vscode/src/handlers/provider.handler.ts create mode 100644 apps/vscode/webview-ui/src/components/ProvidersModal.tsx diff --git a/.changeset/shared-catalog-provider-import.md b/.changeset/shared-catalog-provider-import.md new file mode 100644 index 00000000..1aeb4b34 --- /dev/null +++ b/.changeset/shared-catalog-provider-import.md @@ -0,0 +1,6 @@ +--- +"@pythoughts/pythinker-code-sdk": minor +"@pythoughts/pythinker-code": patch +--- + +Add an SDK routine that imports a catalog provider and its models into the persisted config, and use it for the CLI provider import so both entry points preserve existing defaults the same way. diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 79899f11..3a2be212 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -20,14 +20,13 @@ import { type ManagedPythinkerConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { - applyCatalogProvider, - catalogBaseUrl, catalogConnectionWire, catalogProviderModels, CatalogFetchError, createPythinkerHarness, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, inferWireType, type Catalog, type CatalogProviderEntry, @@ -366,74 +365,20 @@ export async function handleCatalogAdd( } const harness = deps.getHarness(); - await harness.ensureConfigFile(); - - let config = await harness.getConfig(); - - // Capture defaults BEFORE `removeProvider`, because that call clears - // `defaultModel` when it points at one of this provider's aliases (see - // `core-impl.ts removePythinkerProvider`). Without this, re-importing an - // already-configured provider would lose the user's previously-set default - // even when `--default-model` is not supplied. - const previousDefaultProvider = config.defaultProvider; - const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; - - if (config.providers[providerId] !== undefined) { - config = await harness.removeProvider(providerId); - } - - const baseUrl = catalogBaseUrl(entry, wire); - // `applyCatalogProvider` always overwrites both `defaultModel` and - // `defaultThinking`. The values we pass here are temporary; we restore - // a consistent state in the post-apply block below. - applyCatalogProvider(config, { - providerId, - catalogUrl: url, - wire, - baseUrl, - apiKey: useEnvVar ? undefined : literalApiKey, - apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, - models, - selectedModelId: opts.defaultModel ?? '', - thinking: false, - }); - - // Resolve the final `defaultModel`: - // - If the caller asked for one, `applyCatalogProvider` already set it. - // - Else, restore the previous default ONLY when its alias still resolves - // after the catalog refresh; the catalog may have dropped the old - // model, in which case restoring would point default_model at a - // non-existent alias and break the next session. - if (opts.defaultModel === undefined) { - const stillResolves = - previousDefaultModel !== undefined && - config.models?.[previousDefaultModel] !== undefined; - config.defaultModel = stillResolves ? previousDefaultModel : undefined; + try { + await importCatalogProvider(harness, { + providerId, + entry, + catalogUrl: url, + apiKey: useEnvVar ? undefined : literalApiKey, + apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, + defaultModel: opts.defaultModel, + }); + } catch (error) { + deps.stderr.write(`${errorMessage(error)}\n`); + deps.exit(1); } - // Mirror the defaultModel handling for defaultProvider: `removeProvider` - // cleared it, and the catalog refresh may have dropped the provider id. - config.defaultProvider = - previousDefaultProvider !== undefined && config.providers[previousDefaultProvider] !== undefined - ? previousDefaultProvider - : undefined; - - // Always restore `defaultThinking` from what was there before — including - // `undefined`. Persisting `false` when the user never set it would make - // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat - // it as an explicit "off" request and silently disable thinking, even - // for thinking-capable models. - config.defaultThinking = previousDefaultThinking; - - await harness.setConfig({ - providers: config.providers, - models: config.models, - defaultProvider: config.defaultProvider, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - const displayName = entry.name ?? providerId; deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 3eafc5b1..683c68ff 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -25,6 +25,11 @@ export const Methods = { OpenFolder: "openFolder", GetModels: "getModels", + GetProviders: "getProviders", + GetProviderCatalog: "getProviderCatalog", + AddCatalogProvider: "addCatalogProvider", + RemoveProvider: "removeProvider", + GetMCPServers: "getMCPServers", AddMCPServer: "addMCPServer", UpdateMCPServer: "updateMCPServer", @@ -90,6 +95,7 @@ export type RpcMessageValidation = export const Events = { ExtensionConfigChanged: "extensionConfigChanged", MCPServersChanged: "mcpServersChanged", + ProvidersChanged: "providersChanged", StreamEvent: "streamEvent", FocusInput: "focusInput", InsertMention: "insertMention", @@ -139,6 +145,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.OpenSettings: case Methods.OpenFolder: case Methods.GetModels: + case Methods.GetProviders: + case Methods.GetProviderCatalog: case Methods.GetMCPServers: case Methods.AbortChat: case Methods.ResetSession: @@ -167,6 +175,14 @@ function validateParams(method: RpcMethod, params: unknown): boolean { && isOptionalType(params["enableNewConversationShortcut"], "boolean") && isOptionalType(params["showThinkingContent"], "boolean") && isOptionalType(params["showThinkingExpanded"], "boolean"); + case Methods.AddCatalogProvider: + return isPlainObject(params) + && isNonEmptyString(params["providerId"]) + && isOptionalType(params["apiKey"], "string") + && isOptionalType(params["apiKeyEnvVar"], "string") + && isOptionalType(params["defaultModel"], "string"); + case Methods.RemoveProvider: + return hasNonEmptyString(params, "providerId"); case Methods.AddMCPServer: return isMcpServerConfig(params); case Methods.UpdateMCPServer: diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index 1b2c5a3c..66f1facd 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -69,3 +69,41 @@ export interface LoginStatus { } export type { QuestionRequest, QuestionItem, QuestionOption, QuestionResponse } from "./legacy-sdk"; + +/** A provider as it exists in config.toml. The API key itself never crosses the bridge. */ +export interface ConfiguredProvider { + id: string; + type: string; + baseUrl?: string; + keySource: "config" | "env" | "none"; + apiKeyEnvVar?: string; + catalogUrl?: string; + models: string[]; +} + +export interface ProvidersView { + providers: ConfiguredProvider[]; + defaultModel: string | null; +} + +export interface CatalogModelSummary { + id: string; + name: string; + maxContextTokens?: number; + thinking: boolean; +} + +export interface CatalogProviderSummary { + id: string; + name: string; + wire?: string; + apiKeyEnvVar?: string; + models: CatalogModelSummary[]; +} + +export interface AddCatalogProviderRequest { + providerId: string; + apiKey?: string; + apiKeyEnvVar?: string; + defaultModel?: string; +} diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts index fa0a72b1..9631d31f 100644 --- a/apps/vscode/src/handlers/index.ts +++ b/apps/vscode/src/handlers/index.ts @@ -1,5 +1,6 @@ import { configHandlers } from "./config.handler"; import { mcpHandlers } from "./mcp.handler"; +import { providerHandlers } from "./provider.handler"; import { sessionHandlers } from "./session.handler"; import { chatHandlers } from "./chat.handler"; import { fileHandlers } from "./file.handler"; @@ -13,6 +14,7 @@ export const handlers: Record> = { ...workspaceHandlers, ...configHandlers, ...mcpHandlers, + ...providerHandlers, ...sessionHandlers, ...chatHandlers, ...fileHandlers, diff --git a/apps/vscode/src/handlers/provider.handler.ts b/apps/vscode/src/handlers/provider.handler.ts new file mode 100644 index 00000000..0347675f --- /dev/null +++ b/apps/vscode/src/handlers/provider.handler.ts @@ -0,0 +1,130 @@ +import { + CatalogProviderError, + DEFAULT_CATALOG_URL, + catalogConnectionWire, + catalogProviderModels, + fetchCatalog, + importCatalogProvider, + type Catalog, +} from "@pythoughts/pythinker-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { + AddCatalogProviderRequest, + CatalogProviderSummary, + ConfiguredProvider, + ProvidersView, +} from "../../shared/types"; +import type { Handler, HandlerContext } from "./types"; + +/** + * The catalog is a ~1 MB public document that changes rarely. One fetch per + * extension host is enough; the modal reads it repeatedly while the user browses. + */ +let catalogCache: Promise | undefined; + +async function loadCatalog(): Promise { + catalogCache ??= fetchCatalog(DEFAULT_CATALOG_URL).catch((error: unknown) => { + catalogCache = undefined; + throw error; + }); + return catalogCache; +} + +async function readProviders(ctx: HandlerContext): Promise { + const config = await ctx.harness.getConfig({ reload: true }); + const providers: ConfiguredProvider[] = Object.entries(config.providers ?? {}) + .map(([id, provider]) => toConfiguredProvider(id, provider, config)) + .toSorted((left, right) => left.id.localeCompare(right.id)); + return { providers, defaultModel: config.defaultModel ?? null }; +} + +function toConfiguredProvider(id: string, provider: any, config: any): ConfiguredProvider { + const models = Object.entries(config.models ?? {}) + .filter(([, alias]) => (alias as any).provider === id) + .map(([alias]) => alias); + return { + id, + type: provider.type ?? "unknown", + baseUrl: provider.baseUrl, + // Never send the key itself to the Webview; only whether one is configured + // and where it comes from. + keySource: + typeof provider.apiKey === "string" && provider.apiKey.length > 0 + ? "config" + : typeof provider.apiKeyEnvVar === "string" && provider.apiKeyEnvVar.length > 0 + ? "env" + : "none", + apiKeyEnvVar: provider.apiKeyEnvVar, + catalogUrl: provider.source?.kind === "modelsDev" ? provider.source.url : undefined, + models: models.toSorted((left, right) => left.localeCompare(right)), + }; +} + +export const providerHandlers: Record> = { + [Methods.GetProviders]: async (_, ctx): Promise => readProviders(ctx), + + [Methods.GetProviderCatalog]: async (): Promise => { + const catalog = await loadCatalog(); + return Object.entries(catalog) + .map(([id, entry]) => toCatalogSummary(id, entry)) + // A provider that cannot be reached with a single API key has no path + // through this UI, so it is not offered. + .filter((entry) => entry.wire !== undefined && entry.models.length > 0) + .toSorted((left, right) => left.name.localeCompare(right.name)); + }, + + [Methods.AddCatalogProvider]: async ( + params: AddCatalogProviderRequest, + ctx, + ): Promise => { + const catalog = await loadCatalog(); + const entry = catalog[params.providerId]; + if (entry === undefined) { + throw new Error(`Provider "${params.providerId}" is not in the catalog.`); + } + try { + await importCatalogProvider(ctx.harness, { + providerId: params.providerId, + entry, + catalogUrl: DEFAULT_CATALOG_URL, + apiKey: params.apiKey, + apiKeyEnvVar: params.apiKeyEnvVar, + defaultModel: params.defaultModel, + }); + } catch (error) { + // The import errors are already written for a person to read. + if (error instanceof CatalogProviderError) throw new Error(error.message, { cause: error }); + throw error; + } + const view = await readProviders(ctx); + ctx.broadcast(Events.ProvidersChanged, view); + return view; + }, + + [Methods.RemoveProvider]: async ( + { providerId }: { providerId: string }, + ctx, + ): Promise => { + await ctx.harness.removeProvider(providerId); + const view = await readProviders(ctx); + ctx.broadcast(Events.ProvidersChanged, view); + return view; + }, +}; + +function toCatalogSummary(id: string, entry: any): CatalogProviderSummary { + const wire = catalogConnectionWire(entry); + return { + id, + name: entry.name ?? id, + wire, + apiKeyEnvVar: entry.env?.[0], + models: catalogProviderModels(entry).map((model) => ({ + id: model.id, + name: model.name ?? model.id, + maxContextTokens: model.capability?.max_context_tokens, + thinking: model.capability?.thinking === true, + })), + }; +} diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 20f59d70..b5b18c98 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -26,6 +26,8 @@ const host = vi.hoisted(() => { close: vi.fn(async () => undefined), getConfig: vi.fn(), setConfig: vi.fn(async () => undefined), + ensureConfigFile: vi.fn(async () => undefined), + removeProvider: vi.fn(async () => undefined), listSessions: vi.fn(async () => []), resumeSession: vi.fn(), forkSession: vi.fn(), @@ -566,3 +568,98 @@ function createResumedSession(id: string, workDir: string) { onEvent: () => () => undefined, }; } + +describe("Webview provider management (writes the same config.toml the CLI reads)", () => { + const catalog = { + anthropic: { + id: "anthropic", + name: "Anthropic", + api: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + env: ["ANTHROPIC_API_KEY"], + models: { m1: { id: "m1", name: "M1", limit: { context: 200000, output: 64000 } } }, + }, + unusable: { id: "unusable", name: "Unusable", models: {} }, + }; + + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(catalog), { status: 200 })), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("lists configured providers without ever sending the API key", async () => { + host.harness.getConfig.mockResolvedValue({ + providers: { anthropic: { type: "anthropic", apiKey: "sk-secret", baseUrl: "https://api.anthropic.com" } }, + models: { "anthropic/m1": { provider: "anthropic", model: "m1" } }, + defaultModel: "anthropic/m1", + } as never); + + const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviders }, "view-1"); + + const result = (response as { result: any }).result; + expect(result.providers).toEqual([ + expect.objectContaining({ id: "anthropic", keySource: "config", models: ["anthropic/m1"] }), + ]); + expect(JSON.stringify(result)).not.toContain("sk-secret"); + }); + + it("offers only catalog providers that a single key can reach", async () => { + const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviderCatalog }, "view-1"); + + const result = (response as { result: any }).result; + expect(result.map((entry: any) => entry.id)).toEqual(["anthropic"]); + expect(result[0].models).toEqual([expect.objectContaining({ id: "m1" })]); + }); + + it("imports a catalog provider into the config", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + const response = await bridge.handle( + { + id: "rpc-1", + method: Methods.AddCatalogProvider, + params: { providerId: "anthropic", apiKey: "sk-test", defaultModel: "m1" }, + }, + "view-1", + ); + + expect((response as { error?: unknown }).error).toBeUndefined(); + expect(host.harness.setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + providers: expect.objectContaining({ + anthropic: expect.objectContaining({ apiKey: "sk-test" }), + }), + defaultModel: "anthropic/m1", + }), + ); + }); + + it("rejects an import with no key rather than writing a broken provider", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + const response = await bridge.handle( + { id: "rpc-1", method: Methods.AddCatalogProvider, params: { providerId: "anthropic" } }, + "view-1", + ); + + expect((response as { error?: string }).error).toMatch(/needs an API key/); + expect(host.harness.setConfig).not.toHaveBeenCalled(); + }); + + it("removes a provider through the harness", async () => { + host.harness.getConfig.mockResolvedValue({ providers: {} } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.RemoveProvider, params: { providerId: "anthropic" } }, + "view-1", + ); + + expect(host.harness.removeProvider).toHaveBeenCalledWith("anthropic"); + }); +}); diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 2495bc2c..e047a1c5 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -4,6 +4,7 @@ import { Header } from "./components/Header"; import { ChatArea } from "./components/ChatArea"; import { InputArea } from "./components/inputarea/InputArea"; import { MCPServersModal } from "./components/MCPServersModal"; +import { ProvidersModal } from "./components/ProvidersModal"; import { WorkDirModal } from "./components/WorkDirModal"; import { SettingsDialog } from "./components/SettingsDialog"; import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; @@ -74,6 +75,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
+ diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx index ed9e58b3..23c02ae7 100644 --- a/apps/vscode/webview-ui/src/components/ActionMenu.tsx +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { IconSettings, IconServer, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; +import { IconSettings, IconServer, IconPlug, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -44,13 +44,18 @@ function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); - const { setMCPModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); + const { setMCPModalOpen, setProvidersModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); const handleOpenSettings = () => { void bridge.openSettings(); setOpen(false); }; + const handleOpenProviders = () => { + setProvidersModalOpen(true); + setOpen(false); + }; + const handleOpenMCPServers = () => { setMCPModalOpen(true); setOpen(false); @@ -105,6 +110,10 @@ export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { Working Directory + + + Model Providers + MCP Servers diff --git a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx new file mode 100644 index 00000000..8cc8cd10 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx @@ -0,0 +1,423 @@ +import { useEffect, useMemo, useState } from "react"; +import { + IconCheck, + IconChevronDown, + IconKey, + IconLoader2, + IconPlug, + IconPlus, + IconSearch, + IconTrash, + IconX, +} from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { bridge } from "@/services"; +import { useSettingsStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import type { + CatalogProviderSummary, + ConfiguredProvider, + ProvidersView, +} from "shared/types"; + +const KEY_SOURCE_LABEL: Record = { + config: "Key in config.toml", + env: "Key from environment", + none: "No key configured", +}; + +export function ProvidersModal() { + const { providersModalOpen, setProvidersModalOpen } = useSettingsStore(); + const [view, setView] = useState({ providers: [], defaultModel: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [addOpen, setAddOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(); + + useEffect(() => { + if (!providersModalOpen) return; + setLoading(true); + setError(undefined); + bridge + .getProviders() + .then(setView) + .catch((error: unknown) => setError(messageOf(error))) + .finally(() => setLoading(false)); + }, [providersModalOpen]); + + if (!providersModalOpen) return null; + + const remove = async (providerId: string) => { + setDeleteTarget(undefined); + setError(undefined); + try { + setView(await bridge.removeProvider(providerId)); + } catch (error) { + setError(messageOf(error)); + } + }; + + return ( + <> +
+
+
+ +

Model Providers

+
+
+ + +
+
+ +
+
+ {error && ( +
+ {error} +
+ )} + +

+ Providers live in ~/.pythinker-code/config.toml, the same file the + CLI reads. Anything added here works in the terminal too. +

+ + {loading && view.providers.length === 0 ? ( +
+ + Reading config.toml… +
+ ) : view.providers.length === 0 ? ( +
+

No providers configured yet.

+ +
+ ) : ( + view.providers.map((provider) => ( + setDeleteTarget(provider.id)} + /> + )) + )} +
+
+
+ + {addOpen && ( + setAddOpen(false)} + onAdded={(next) => { + setView(next); + setAddOpen(false); + }} + /> + )} + + !open && setDeleteTarget(undefined)}> + + + Remove {deleteTarget}? + + Its models disappear from the model picker, in the extension and in the CLI. The API key is deleted + from config.toml. + + + + Cancel + deleteTarget && void remove(deleteTarget)}>Remove + + + + + ); +} + +function ProviderRow({ + provider, + defaultModel, + onRemove, +}: { + provider: ConfiguredProvider; + defaultModel: string | null; + onRemove: () => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + + + {provider.keySource === "env" ? provider.apiKeyEnvVar : KEY_SOURCE_LABEL[provider.keySource]} + + +
+ + {expanded && ( +
+ {provider.baseUrl && ( +
{provider.baseUrl}
+ )} + {provider.models.map((model) => ( +
+ {model === defaultModel && } + + {model} + +
+ ))} +
+ )} +
+ ); +} + +function AddProviderPanel({ + onClose, + onAdded, +}: { + onClose: () => void; + onAdded: (view: ProvidersView) => void; +}) { + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(); + const [apiKey, setApiKey] = useState(""); + const [useEnvVar, setUseEnvVar] = useState(false); + const [defaultModel, setDefaultModel] = useState(); + const [saving, setSaving] = useState(false); + + useEffect(() => { + bridge + .getProviderCatalog() + .then(setCatalog) + .catch((error: unknown) => setError(messageOf(error))) + .finally(() => setLoading(false)); + }, []); + + const matches = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return catalog; + return catalog.filter( + (entry) => entry.name.toLowerCase().includes(needle) || entry.id.toLowerCase().includes(needle), + ); + }, [catalog, query]); + + const submit = async () => { + if (selected === undefined) return; + setSaving(true); + setError(undefined); + try { + onAdded( + await bridge.addCatalogProvider({ + providerId: selected.id, + apiKey: useEnvVar ? undefined : apiKey.trim(), + apiKeyEnvVar: useEnvVar ? selected.apiKeyEnvVar : undefined, + defaultModel, + }), + ); + } catch (error) { + setError(messageOf(error)); + } finally { + setSaving(false); + } + }; + + const canSubmit = selected !== undefined && (useEnvVar ? selected.apiKeyEnvVar !== undefined : apiKey.trim().length > 0); + + return ( +
+
+

{selected === undefined ? "Choose a provider" : `Connect ${selected.name}`}

+ +
+ +
+
+ {error && ( +
+ {error} +
+ )} + + {selected === undefined ? ( + <> +
+ + setQuery(event.target.value)} + placeholder="Search providers…" + className="h-7 text-xs pl-7" + /> +
+ {loading ? ( +
+ + Loading the models.dev catalog… +
+ ) : ( +
+ {matches.map((entry) => ( + + ))} + {matches.length === 0 && ( +

No provider matches “{query}”.

+ )} +
+ )} + + ) : ( + <> +
+ + {useEnvVar ? ( +

+ Read at connection time from{" "} + {selected.apiKeyEnvVar}. The key is never written to disk. +

+ ) : ( + setApiKey(event.target.value)} + placeholder="sk-…" + className="h-7 text-xs font-mono" + /> + )} + {selected.apiKeyEnvVar !== undefined && ( + + )} + {!useEnvVar && ( +

+ Stored in config.toml in plain text, as the CLI does. +

+ )} +
+ +
+ +
+ {selected.models.map((model) => ( + + ))} +
+

+ All {selected.models.length} models are imported either way. Leave this unset to keep your current + default. +

+
+ + )} +
+
+ + {selected !== undefined && ( +
+ + +
+ )} +
+ ); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/vscode/webview-ui/src/services/bridge.ts b/apps/vscode/webview-ui/src/services/bridge.ts index 47a52c21..b2505e77 100644 --- a/apps/vscode/webview-ui/src/services/bridge.ts +++ b/apps/vscode/webview-ui/src/services/bridge.ts @@ -10,9 +10,12 @@ import type { UpdateMCPServerRequest, } from "shared/legacy-sdk"; import type { + AddCatalogProviderRequest, + CatalogProviderSummary, FileChange, SessionConfig, ExtensionConfig, + ProvidersView, WorkspaceStatus, LoginStatus, UIStreamEvent, @@ -26,6 +29,8 @@ interface PendingRequest { const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; const OAUTH_REQUEST_TIMEOUT_MS = 16 * 60 * 1000; +/** The catalog is ~1 MB over the public network, and an import writes config.toml. */ +const CATALOG_REQUEST_TIMEOUT_MS = 60 * 1000; interface VSCodeAPI { postMessage(message: unknown): void; @@ -158,6 +163,22 @@ class Bridge { return this.call(Methods.GetModels); } + getProviders() { + return this.call(Methods.GetProviders); + } + + getProviderCatalog() { + return this.call(Methods.GetProviderCatalog, undefined, CATALOG_REQUEST_TIMEOUT_MS); + } + + addCatalogProvider(request: AddCatalogProviderRequest) { + return this.call(Methods.AddCatalogProvider, request, CATALOG_REQUEST_TIMEOUT_MS); + } + + removeProvider(providerId: string) { + return this.call(Methods.RemoveProvider, { providerId }); + } + getMCPServers() { return this.call(Methods.GetMCPServers); } diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 42876898..5af520cb 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -145,6 +145,7 @@ interface SettingsState { extensionConfig: ExtensionConfig; mcpServers: MCPServerConfig[]; mcpModalOpen: boolean; + providersModalOpen: boolean; workDirModalOpen: boolean; settingsDialogOpen: boolean; currentWorkDir: string | null; @@ -166,6 +167,7 @@ interface SettingsState { setExtensionConfig: (config: ExtensionConfig) => void; setMCPServers: (servers: MCPServerConfig[]) => void; setMCPModalOpen: (open: boolean) => void; + setProvidersModalOpen: (open: boolean) => void; setWorkDirModalOpen: (open: boolean) => void; setSettingsDialogOpen: (open: boolean) => void; setCurrentWorkDir: (workDir: string | null) => void; @@ -182,6 +184,7 @@ export const useSettingsStore = create((set, get) => ({ extensionConfig: DEFAULT_EXTENSION_CONFIG, mcpServers: [], mcpModalOpen: false, + providersModalOpen: false, workDirModalOpen: false, settingsDialogOpen: false, currentWorkDir: null, @@ -281,6 +284,7 @@ export const useSettingsStore = create((set, get) => ({ setMCPServers: (mcpServers) => set({ mcpServers }), setMCPModalOpen: (mcpModalOpen) => set({ mcpModalOpen }), + setProvidersModalOpen: (providersModalOpen) => set({ providersModalOpen }), setWorkDirModalOpen: (workDirModalOpen) => set({ workDirModalOpen }), diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 7c6e6120..b55dc889 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -136,3 +136,111 @@ export function applyCatalogProvider( config.defaultThinking = options.thinking; return { defaultModel }; } + +export interface CatalogProviderStore { + ensureConfigFile(): Promise; + getConfig(): Promise; + removeProvider(providerId: string): Promise; + setConfig(patch: Partial): Promise; +} + +export interface ImportCatalogProviderOptions { + readonly providerId: string; + readonly entry: CatalogProviderEntry; + readonly catalogUrl?: string; + /** Literal key, written into config.toml. Mutually exclusive with `apiKeyEnvVar`. */ + readonly apiKey?: string; + /** Name of an environment variable the key is read from at connection time. */ + readonly apiKeyEnvVar?: string; + /** Alias to make the default model, as a bare catalog model id. */ + readonly defaultModel?: string; +} + +export interface ImportCatalogProviderResult { + readonly models: readonly CatalogModel[]; + readonly defaultModel: string | undefined; +} + +export class CatalogProviderError extends Error {} + +/** + * Writes one catalog provider and its models into the persisted config. + * + * Re-importing a configured provider has to drop its stale aliases first, and + * `removeProvider` clears any default pointing at them — so the defaults are + * snapshotted up front and restored afterwards, but only while they still + * resolve against the refreshed catalog. A default left pointing at a model the + * catalog no longer lists would break the next session. + */ +export async function importCatalogProvider( + store: CatalogProviderStore, + options: ImportCatalogProviderOptions, +): Promise { + const { providerId, entry } = options; + const wire = catalogConnectionWire(entry); + if (wire === undefined) { + throw new CatalogProviderError( + `Provider "${providerId}" cannot be configured with a single API key.`, + ); + } + + const apiKey = options.apiKey?.trim(); + const apiKeyEnvVar = options.apiKeyEnvVar?.trim(); + if ((apiKey ?? '').length === 0 && (apiKeyEnvVar ?? '').length === 0) { + throw new CatalogProviderError(`Provider "${providerId}" needs an API key.`); + } + + const models = catalogProviderModels(entry); + if (models.length === 0) { + throw new CatalogProviderError(`Provider "${providerId}" lists no usable models.`); + } + if (options.defaultModel !== undefined && !models.some((m) => m.id === options.defaultModel)) { + throw new CatalogProviderError( + `Model "${options.defaultModel}" is not offered by provider "${providerId}".`, + ); + } + + await store.ensureConfigFile(); + let config = await store.getConfig(); + const previousDefaultProvider = config.defaultProvider; + const previousDefaultModel = config.defaultModel; + const previousDefaultThinking = config.defaultThinking; + + if (config.providers[providerId] !== undefined) { + config = await store.removeProvider(providerId); + } + + const useEnvVar = (apiKey ?? '').length === 0; + applyCatalogProvider(config, { + providerId, + catalogUrl: options.catalogUrl ?? DEFAULT_CATALOG_URL, + wire, + baseUrl: catalogBaseUrl(entry, wire), + apiKey: useEnvVar ? undefined : apiKey, + apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, + models, + selectedModelId: options.defaultModel ?? '', + thinking: false, + }); + + if (options.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + config.defaultProvider = + previousDefaultProvider !== undefined && config.providers[previousDefaultProvider] !== undefined + ? previousDefaultProvider + : undefined; + config.defaultThinking = previousDefaultThinking; + + await store.setConfig({ + providers: config.providers, + models: config.models, + defaultProvider: config.defaultProvider, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + return { models, defaultModel: config.defaultModel }; +} diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index fd86077e..a79c3f6a 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -28,8 +28,10 @@ export { catalogModelToAlias, catalogProviderModels, CatalogFetchError, + CatalogProviderError, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, inferWireType, loadBuiltInCatalog, } from '#/catalog'; @@ -38,6 +40,9 @@ export type { Catalog, CatalogModel, CatalogProviderEntry, + CatalogProviderStore, + ImportCatalogProviderOptions, + ImportCatalogProviderResult, } from '#/catalog'; export { diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index df69df1c..3dae19f3 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -8,6 +8,7 @@ import { CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, + importCatalogProvider, type CatalogModel, } from '../src/catalog'; @@ -192,3 +193,120 @@ describe('applyCatalogProvider', () => { expect(config.models?.['other/keep']).toBeDefined(); }); }); + +describe('importCatalogProvider', () => { + function createStore(initial: Partial = {}) { + let config = { providers: {}, ...initial } as PythinkerConfig; + const patches: Array> = []; + const removed: string[] = []; + return { + patches, + removed, + current: () => config, + store: { + ensureConfigFile: async () => undefined, + getConfig: async () => structuredClone(config), + removeProvider: async (providerId: string) => { + removed.push(providerId); + const providers = { ...config.providers }; + delete providers[providerId]; + const models = Object.fromEntries( + Object.entries(config.models ?? {}).filter(([, alias]) => alias.provider !== providerId), + ); + // Mirrors the core: a default pointing at the removed provider is cleared. + config = { + ...config, + providers, + models, + defaultProvider: config.defaultProvider === providerId ? undefined : config.defaultProvider, + defaultModel: + config.defaultModel !== undefined && models[config.defaultModel] === undefined + ? undefined + : config.defaultModel, + } as PythinkerConfig; + return structuredClone(config); + }, + setConfig: async (patch: Partial) => { + patches.push(patch); + config = { ...config, ...patch } as PythinkerConfig; + return structuredClone(config); + }, + }, + }; + } + + const entry = { + id: 'anthropic', + name: 'Anthropic', + api: 'https://api.anthropic.com', + npm: '@ai-sdk/anthropic', + env: ['ANTHROPIC_API_KEY'], + models: { + m1: { id: 'm1', name: 'M1', limit: { context: 200000, output: 64000 } }, + }, + } as unknown as Parameters[1]['entry']; + + it('writes the provider, its aliases, and the requested default model', async () => { + const rig = createStore(); + + const result = await importCatalogProvider(rig.store, { + providerId: 'anthropic', + entry, + apiKey: 'test-key', + defaultModel: 'm1', + }); + + expect(result.defaultModel).toBe('anthropic/m1'); + expect(rig.current().providers['anthropic']).toMatchObject({ apiKey: 'test-key' }); + expect(rig.current().models?.['anthropic/m1']).toBeDefined(); + }); + + it("keeps a default that belongs to another provider when re-importing", async () => { + const rig = createStore({ + providers: { anthropic: { type: 'anthropic' }, other: { type: 'openai' } }, + models: { 'other/x': { provider: 'other', model: 'x' }, 'anthropic/old': { provider: 'anthropic', model: 'old' } }, + defaultModel: 'other/x', + defaultProvider: 'other', + defaultThinking: true, + } as Partial); + + await importCatalogProvider(rig.store, { providerId: 'anthropic', entry, apiKey: 'k' }); + + expect(rig.removed).toEqual(['anthropic']); + expect(rig.current().defaultModel).toBe('other/x'); + expect(rig.current().defaultProvider).toBe('other'); + expect(rig.current().defaultThinking).toBe(true); + expect(rig.current().models?.['anthropic/old']).toBeUndefined(); + }); + + it('clears a default the refreshed catalog no longer offers', async () => { + const rig = createStore({ + providers: { anthropic: { type: 'anthropic' } }, + models: { 'anthropic/gone': { provider: 'anthropic', model: 'gone' } }, + defaultModel: 'anthropic/gone', + } as Partial); + + await importCatalogProvider(rig.store, { providerId: 'anthropic', entry, apiKey: 'k' }); + + expect(rig.current().defaultModel).toBeUndefined(); + }); + + it('rejects an import with neither a key nor an env var', async () => { + const rig = createStore(); + await expect( + importCatalogProvider(rig.store, { providerId: 'anthropic', entry }), + ).rejects.toThrow(/needs an API key/); + }); + + it('rejects a default model the provider does not offer', async () => { + const rig = createStore(); + await expect( + importCatalogProvider(rig.store, { + providerId: 'anthropic', + entry, + apiKey: 'k', + defaultModel: 'nope', + }), + ).rejects.toThrow(/is not offered/); + }); +}); From 4b10a69e281fd364938b225c30440c9a14f7a6c1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:00:18 -0400 Subject: [PATCH 09/36] fix(vscode): list the session's skills in the slash menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills never appeared in the extension's command menu, so they could only be run by typing the command by hand. The listing called `harness.listWorkspaceSkills`, which does not exist on the SDK harness — the optional call resolved to nothing every time, and the test that covered it mocked the same non-existent method, so the suite agreed. The catalog is session-scoped, so the list now comes from `session.listSkills` as it does in the CLI, and the commands are re-broadcast when a session is created or resumed — the Webview loads before a session exists, which is why a one-shot fetch at startup could never carry them. Built-in skills keep their bare command name, which the host parser previously could not route: it only recognized the `skill:` prefix, so a built-in skill invoked by name was sent to the model as plain text. The projection from a skill catalog to slash commands now lives in the SDK, shared with the CLI instead of duplicated per client. --- .../pythinker-code/src/tui/commands/skills.ts | 54 ++------------ apps/vscode/shared/bridge.ts | 1 + apps/vscode/src/bridge-handler.ts | 18 +++++ apps/vscode/src/handlers/chat.handler.ts | 2 +- apps/vscode/src/handlers/config.handler.ts | 36 +++++---- apps/vscode/src/handlers/slash-command.ts | 36 +++++++-- .../pythinker-harness.integration.test.ts | 73 +++++++++++++++---- apps/vscode/webview-ui/src/App.tsx | 5 +- packages/node-sdk/src/index.ts | 3 + packages/node-sdk/src/skill-commands.ts | 64 ++++++++++++++++ packages/node-sdk/test/catalog.test.ts | 7 +- 11 files changed, 209 insertions(+), 90 deletions(-) create mode 100644 packages/node-sdk/src/skill-commands.ts diff --git a/apps/pythinker-code/src/tui/commands/skills.ts b/apps/pythinker-code/src/tui/commands/skills.ts index e3cbd350..6452df50 100644 --- a/apps/pythinker-code/src/tui/commands/skills.ts +++ b/apps/pythinker-code/src/tui/commands/skills.ts @@ -1,56 +1,16 @@ -import type { Session, SkillSummary } from '@pythoughts/pythinker-code-sdk'; +import { + buildSkillSlashCommands, + isUserActivatableSkill, + type Session, +} from '@pythoughts/pythinker-code-sdk'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import type { SlashCommandHost } from './dispatch'; -import type { PythinkerSlashCommand } from './types'; export type SkillListSession = Pick; -export interface SkillSlashCommands { - readonly commands: readonly PythinkerSlashCommand[]; - readonly commandMap: ReadonlyMap; -} - -export function isUserActivatableSkill(skill: SkillSummary): boolean { - return ( - skill.userInvocable !== false && - (skill.type === undefined || - skill.type === 'prompt' || - skill.type === 'inline' || - skill.type === 'flow') - ); -} - -function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { - return ( - getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) || - a.name.localeCompare(b.name) - ); -} - -function getSkillSlashCommandGroup(source: SkillSummary['source']): number { - return source === 'builtin' ? 0 : 1; -} - -export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { - const commandMap = new Map(); - const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); - const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { - const commandName = - skill.commandName ?? - (skill.source === 'builtin' || skill.isSubSkill === true - ? skill.name - : `skill:${skill.name}`); - commandMap.set(commandName, skill.name); - return { - name: commandName, - aliases: [], - description: skill.description ?? '', - argumentHint: skill.argumentHint, - }; - }); - return { commands, commandMap }; -} +export type { SkillSlashCommands } from '@pythoughts/pythinker-code-sdk'; +export { buildSkillSlashCommands, isUserActivatableSkill }; export async function handleSkillsCommand( host: SlashCommandHost, diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index 683c68ff..a8116ae7 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -96,6 +96,7 @@ export const Events = { ExtensionConfigChanged: "extensionConfigChanged", MCPServersChanged: "mcpServersChanged", ProvidersChanged: "providersChanged", + SlashCommandsChanged: "slashCommandsChanged", StreamEvent: "streamEvent", FocusInput: "focusInput", InsertMention: "insertMention", diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts index 8799bf06..7033981d 100644 --- a/apps/vscode/src/bridge-handler.ts +++ b/apps/vscode/src/bridge-handler.ts @@ -2,11 +2,13 @@ import * as path from "node:path"; import * as vscode from "vscode"; import { + Events, validateRpcMessage, type RpcMethod, type RpcResult, } from "../shared/bridge"; import { VSCodeSettings } from "./config/vscode-settings"; +import { getSlashCommands } from "./handlers/config.handler"; import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers"; import { BaselineManager, type BaselineSession } from "./managers/baseline.manager"; import { FileManager } from "./managers/file.manager"; @@ -161,6 +163,7 @@ export class BridgeHandler { ...(sessionId === undefined ? {} : { sessionId }), }); this.fileManager.setSession(webviewId, baselineSession(runtime)); + void this.broadcastSlashCommands(webviewId); return runtime; }, resumeSession: async (sessionId) => { @@ -182,6 +185,7 @@ export class BridgeHandler { VSCodeSettings.yoloMode, ); this.fileManager.setSession(webviewId, baselineSession(runtime)); + void this.broadcastSlashCommands(webviewId); return runtime; }, closeSession: async () => { @@ -193,6 +197,20 @@ export class BridgeHandler { }; } + /** + * The skill catalog only exists once a session does, so the command list the + * Webview loaded at startup is missing every skill. Re-push it on session + * create/resume rather than making the Webview poll. + */ + private async broadcastSlashCommands(webviewId: string): Promise { + try { + const commands = await getSlashCommands(undefined, this.createContext(webviewId)); + this.broadcast(Events.SlashCommandsChanged, commands, webviewId); + } catch (error) { + this.logRuntimeError("Unable to refresh the slash commands", error); + } + } + private async saveAllDirty(): Promise { const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled); await Promise.all(dirty.map((document) => document.save())); diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index 3870cf5c..ba46b4e0 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -124,7 +124,7 @@ const streamChat: Handler = async (params, return { done: false }; } - const slash = parseHostSlashCommand(params.content); + const slash = await parseHostSlashCommand(params.content, () => runtime.session.listSkills()); if (slash !== undefined) { try { return { done: await runHostSlashCommand(runtime, slash, ctx) }; diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 4a16e652..2eea7d7f 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { buildSkillSlashCommands, type SkillSlashCommand } from "@pythoughts/pythinker-code-sdk"; type SdkConfig = any; import { Methods } from "../../shared/bridge"; @@ -87,25 +88,31 @@ const getModels: Handler = async (_, ctx) => { return toWebviewConfig(await ctx.harness.getConfig({ reload: true })); }; -const getSlashCommands: Handler = async (_, ctx) => { - if (!ctx.workDir) return SLASH_COMMANDS; +/** + * The skill catalog is session-scoped in the engine, so the list is empty until + * a session exists. `bridge-handler` re-broadcasts the commands once one is + * created, which is what fills the menu on a cold start. + */ +export const getSlashCommands: Handler = async (_, ctx) => { + const session = ctx.getSession()?.session; + if (session === undefined) return SLASH_COMMANDS; try { - const skills = await (ctx.harness as any).listWorkspaceSkills?.(ctx.workDir) ?? []; - const skillCommands = (skills as Array<{ name: string; type: string; description?: string }>) - .filter((skill) => isUserActivatableSkill(skill.type)) - .toSorted((left, right) => left.name.localeCompare(right.name)) - .map((skill) => ({ - name: `skill:${skill.name}`, - aliases: [], - description: skill.description ?? "", - })); - return [...SLASH_COMMANDS, ...skillCommands]; + const { commands } = buildSkillSlashCommands(await session.listSkills()); + return [...SLASH_COMMANDS, ...commands.map(toSlashCommandInfo)]; } catch (error) { - ctx.logError("Unable to list workspace skills", error); + ctx.logError("Unable to list skills", error); return SLASH_COMMANDS; } }; +function toSlashCommandInfo(command: SkillSlashCommand): SlashCommandInfo { + return { + name: command.name, + aliases: [...command.aliases], + description: command.description, + }; +} + const showLogs: Handler = async (_, ctx) => { ctx.showLogs(); return { ok: true }; @@ -154,6 +161,3 @@ function toWebviewModel(id: string, model: any): ModelConfig { }; } -function isUserActivatableSkill(type: string | undefined): boolean { - return type === undefined || type === "prompt" || type === "inline" || type === "flow"; -} diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index 917bf0fc..4a54f6ba 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -4,7 +4,11 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import * as vscode from "vscode"; -import type { PermissionMode } from "@pythoughts/pythinker-code-sdk"; +import { + buildSkillSlashCommands, + type PermissionMode, + type SkillSummary, +} from "@pythoughts/pythinker-code-sdk"; import type { SessionRuntime } from "../runtime/session-runtime"; import { @@ -34,16 +38,36 @@ export interface HostSlashCommand { readonly name: string; readonly args: string; readonly raw: string; + /** Set when the command names a skill; built-in skills are not `skill:`-prefixed. */ + readonly skillName?: string; } -export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { +/** + * `listSkills` is consulted only for a `/word` that is not a host command, so an + * ordinary message never pays for it. Anything that resolves to neither a host + * command nor a skill is left alone and goes to the model as text. + */ +export async function parseHostSlashCommand( + content: string | readonly unknown[], + listSkills?: () => Promise, +): Promise { if (typeof content !== "string") return undefined; const raw = content.trim(); const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); if (match === null) return undefined; const name = match[1]!.toLowerCase(); - if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; - return { name, args: match[2]?.trim() ?? "", raw }; + const args = match[2]?.trim() ?? ""; + if (HOST_COMMANDS.has(name)) return { name, args, raw }; + + if (listSkills === undefined) { + return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined; + } + const { commandMap } = buildSkillSlashCommands(await listSkills()); + const skillName = commandMap.get(name) ?? commandMap.get(match[1]!); + if (skillName !== undefined) return { name, args, raw, skillName }; + // A skill the catalog no longer lists still reaches the engine, which reports + // the miss far better than silently sending "/skill:foo" to the model. + return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined; } export async function runHostSlashCommand( @@ -51,8 +75,8 @@ export async function runHostSlashCommand( command: HostSlashCommand, ctx: HandlerContext, ): Promise { - if (command.name.startsWith("skill:")) { - const skillName = command.name.slice("skill:".length); + if (command.skillName !== undefined) { + const skillName = command.skillName; const result = await runtime.runTurnAction(command.raw, async () => { await runtime.session.activateSkill(skillName, command.args || undefined); }); diff --git a/apps/vscode/test/pythinker-harness.integration.test.ts b/apps/vscode/test/pythinker-harness.integration.test.ts index 0a6b5be4..8a1f14ec 100644 --- a/apps/vscode/test/pythinker-harness.integration.test.ts +++ b/apps/vscode/test/pythinker-harness.integration.test.ts @@ -355,32 +355,62 @@ async function runSlash( raw: string, ctx = {} as HandlerContext, ): Promise { - const command = parseHostSlashCommand(raw); + return (await startSlash(runtime, raw, ctx))(); +} + +/** Parses first and hands back the dispatch, for tests that assert on the busy flag. */ +async function startSlash( + runtime: SessionRuntime, + raw: string, + ctx = {} as HandlerContext, +): Promise<() => Promise> { + const command = await parseHostSlashCommand(raw, () => runtime.session.listSkills()); if (command === undefined) throw new Error(`Expected host slash command: ${raw}`); - return runHostSlashCommand(runtime, command, ctx); + return () => runHostSlashCommand(runtime, command, ctx); } describe("VS Code Pythinker harness integration (shares one in-process SDK home)", () => { - it("only intercepts released slash commands and user-invoked skills", () => { - expect(parseHostSlashCommand("/plan on")).toEqual({ name: "plan", args: "on", raw: "/plan on" }); - expect(parseHostSlashCommand(" /skill:review carefully ")).toEqual({ + it("only intercepts released slash commands and user-invoked skills", async () => { + await expect(parseHostSlashCommand("/plan on")).resolves.toEqual({ + name: "plan", + args: "on", + raw: "/plan on", + }); + await expect(parseHostSlashCommand(" /skill:review carefully ")).resolves.toEqual({ name: "skill:review", args: "carefully", raw: "/skill:review carefully", + skillName: "review", }); - expect(parseHostSlashCommand("/not-a-host-command")).toBeUndefined(); - expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).toBeUndefined(); + await expect(parseHostSlashCommand("/not-a-host-command")).resolves.toBeUndefined(); + await expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).resolves.toBeUndefined(); + }); + + it("resolves a built-in skill invoked under its bare name", async () => { + const listSkills = async () => [ + { name: "gen-changesets", description: "", path: "/s", source: "builtin", type: "prompt" }, + ]; + + await expect( + parseHostSlashCommand("/gen-changesets", listSkills as never), + ).resolves.toMatchObject({ skillName: "gen-changesets" }); + await expect( + parseHostSlashCommand("/still-not-a-command", listSkills as never), + ).resolves.toBeUndefined(); }); - it("combines the released slash commands with user-activatable workspace skills", async () => { + it("combines the released slash commands with the session's user-activatable skills", async () => { const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, { - workDir: "/workspace", - harness: { - listWorkspaceSkills: async () => [ - { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" }, - { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" }, - ], - }, + getSession: () => ({ + session: { + listSkills: async () => [ + { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" }, + { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" }, + { name: "model-only", description: "Model", path: "/skills/m", source: "user", type: "prompt", userInvocable: false }, + { name: "builtin-one", description: "Builtin", path: "/skills/b", source: "builtin", type: "prompt" }, + ], + }, + }), logError: () => undefined, } as unknown as HandlerContext); @@ -394,10 +424,20 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) "add-dir", "export", "import", + "builtin-one", "skill:review", ]); }); + it("falls back to the released commands when no session is open yet", async () => { + const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, { + getSession: () => undefined, + logError: () => undefined, + } as unknown as HandlerContext); + + expect((commands as Array<{ name: string }>).some((command) => command.name.startsWith("skill:"))).toBe(false); + }); + it("sends the package version in User-Agent when VS Code prompts the provider", async () => { const rig = await createRuntimeRig(); routeSuccessfulPrompt(rig.provider); @@ -1143,7 +1183,8 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) await writeFile(join(rig.workDir, "prior.md"), "Enough prior context to compact."); await runSlash(runtime, "/import prior.md", streamChatContext(rig)); - const command = runSlash(runtime, "/compact keep decisions"); + const dispatch = await startSlash(runtime, "/compact keep decisions"); + const command = dispatch(); expect(runtime.isBusy).toBe(true); await expect(command).resolves.toBe(true); diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index e047a1c5..f066d5f0 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -19,7 +19,7 @@ import "./styles/index.css"; function MainContent({ onAuthAction }: { onAuthAction: () => void }) { const { processEvent, startNewConversation, sessionId } = useChatStore(); - const { setMCPServers, setExtensionConfig, extensionConfig } = useSettingsStore(); + const { setMCPServers, setExtensionConfig, extensionConfig, setWireSlashCommands } = useSettingsStore(); useEffect(() => { return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { @@ -41,6 +41,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { useEffect(() => { const unsubs = [ bridge.on(Events.MCPServersChanged, setMCPServers), + bridge.on(Events.SlashCommandsChanged, setWireSlashCommands), bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)), bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()), bridge.on(Events.NewConversation, () => { @@ -50,7 +51,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { }), ]; return () => unsubs.forEach((u) => u()); - }, [setMCPServers, setExtensionConfig, startNewConversation]); + }, [setMCPServers, setExtensionConfig, setWireSlashCommands, startNewConversation]); useEffect(() => { if (!extensionConfig.enableNewConversationShortcut) return; diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index a79c3f6a..cb2e3b62 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -45,6 +45,9 @@ export type { ImportCatalogProviderResult, } from '#/catalog'; +export { buildSkillSlashCommands, isUserActivatableSkill } from '#/skill-commands'; +export type { SkillSlashCommand, SkillSlashCommands } from '#/skill-commands'; + export { ErrorCodes, PythinkerError, diff --git a/packages/node-sdk/src/skill-commands.ts b/packages/node-sdk/src/skill-commands.ts new file mode 100644 index 00000000..23cf63e8 --- /dev/null +++ b/packages/node-sdk/src/skill-commands.ts @@ -0,0 +1,64 @@ +import type { SkillSummary } from '@pythoughts/agent-core'; + +export interface SkillSlashCommand { + readonly name: string; + readonly aliases: readonly string[]; + readonly description: string; + readonly argumentHint?: string; +} + +export interface SkillSlashCommands { + readonly commands: readonly SkillSlashCommand[]; + /** Slash command name → skill name, for dispatching an invocation back to the engine. */ + readonly commandMap: ReadonlyMap; +} + +/** A skill a person can invoke by hand, as opposed to one only the model may load. */ +export function isUserActivatableSkill(skill: SkillSummary): boolean { + return ( + skill.userInvocable !== false && + (skill.type === undefined || + skill.type === 'prompt' || + skill.type === 'inline' || + skill.type === 'flow') + ); +} + +/** Built-in skills come first; everything else keeps alphabetical order. */ +function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { + return ( + skillSlashCommandGroup(a.source) - skillSlashCommandGroup(b.source) || + a.name.localeCompare(b.name) + ); +} + +function skillSlashCommandGroup(source: SkillSummary['source']): number { + return source === 'builtin' ? 0 : 1; +} + +/** + * Projects a skill catalog into slash commands. Built-in skills and sub-skills + * keep their bare name; everything else is namespaced under `skill:` so a + * workspace skill cannot shadow a host command. + */ +export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { + const commandMap = new Map(); + const commands = [...skills] + .toSorted(compareSkillSlashCommands) + .filter(isUserActivatableSkill) + .map((skill) => { + const commandName = + skill.commandName ?? + (skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`); + commandMap.set(commandName, skill.name); + return { + name: commandName, + aliases: [] as readonly string[], + description: skill.description ?? '', + argumentHint: skill.argumentHint, + }; + }); + return { commands, commandMap }; +} diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 3dae19f3..411890af 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -264,7 +264,10 @@ describe('importCatalogProvider', () => { it("keeps a default that belongs to another provider when re-importing", async () => { const rig = createStore({ providers: { anthropic: { type: 'anthropic' }, other: { type: 'openai' } }, - models: { 'other/x': { provider: 'other', model: 'x' }, 'anthropic/old': { provider: 'anthropic', model: 'old' } }, + models: { + 'other/x': { provider: 'other', model: 'x', maxContextSize: 1000 }, + 'anthropic/old': { provider: 'anthropic', model: 'old', maxContextSize: 1000 }, + }, defaultModel: 'other/x', defaultProvider: 'other', defaultThinking: true, @@ -282,7 +285,7 @@ describe('importCatalogProvider', () => { it('clears a default the refreshed catalog no longer offers', async () => { const rig = createStore({ providers: { anthropic: { type: 'anthropic' } }, - models: { 'anthropic/gone': { provider: 'anthropic', model: 'gone' } }, + models: { 'anthropic/gone': { provider: 'anthropic', model: 'gone', maxContextSize: 1000 } }, defaultModel: 'anthropic/gone', } as Partial); From 44da7d87209b64994ccd00e0ca3348f02eee1b1b Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:01:44 -0400 Subject: [PATCH 10/36] fix(vscode): stop reporting a managed provider as missing its key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider that authenticates over OAuth is required by the config schema to carry no apiKey at all, so the providers list flagged it in red as "No key configured" — a warning about a provider that is working. It now reads as signed in, and each row shows the provider's API host rather than only its wire type, which is what identifies a managed provider at a glance. --- apps/vscode/shared/types.ts | 4 ++- apps/vscode/src/handlers/provider.handler.ts | 25 ++++++++++++++----- .../src/components/ProvidersModal.tsx | 5 +++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index 66f1facd..c9b86a30 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -75,7 +75,9 @@ export interface ConfiguredProvider { id: string; type: string; baseUrl?: string; - keySource: "config" | "env" | "none"; + keySource: "config" | "env" | "oauth" | "none"; + /** Host of the provider's base URL, shown so a managed provider is identifiable. */ + host?: string; apiKeyEnvVar?: string; catalogUrl?: string; models: string[]; diff --git a/apps/vscode/src/handlers/provider.handler.ts b/apps/vscode/src/handlers/provider.handler.ts index 0347675f..c2675188 100644 --- a/apps/vscode/src/handlers/provider.handler.ts +++ b/apps/vscode/src/handlers/provider.handler.ts @@ -47,14 +47,18 @@ function toConfiguredProvider(id: string, provider: any, config: any): Configure id, type: provider.type ?? "unknown", baseUrl: provider.baseUrl, + host: hostOf(provider.baseUrl), // Never send the key itself to the Webview; only whether one is configured - // and where it comes from. + // and where it comes from. A managed provider authenticates over OAuth and + // is required by the schema to carry no key at all, so it is not missing one. keySource: - typeof provider.apiKey === "string" && provider.apiKey.length > 0 - ? "config" - : typeof provider.apiKeyEnvVar === "string" && provider.apiKeyEnvVar.length > 0 - ? "env" - : "none", + provider.oauth !== undefined + ? "oauth" + : typeof provider.apiKey === "string" && provider.apiKey.length > 0 + ? "config" + : typeof provider.apiKeyEnvVar === "string" && provider.apiKeyEnvVar.length > 0 + ? "env" + : "none", apiKeyEnvVar: provider.apiKeyEnvVar, catalogUrl: provider.source?.kind === "modelsDev" ? provider.source.url : undefined, models: models.toSorted((left, right) => left.localeCompare(right)), @@ -113,6 +117,15 @@ export const providerHandlers: Record> = { }, }; +function hostOf(baseUrl: unknown): string | undefined { + if (typeof baseUrl !== "string" || baseUrl.length === 0) return undefined; + try { + return new URL(baseUrl).host; + } catch { + return undefined; + } +} + function toCatalogSummary(id: string, entry: any): CatalogProviderSummary { const wire = catalogConnectionWire(entry); return { diff --git a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx index 8cc8cd10..cdcb70f7 100644 --- a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx +++ b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx @@ -36,6 +36,7 @@ import type { const KEY_SOURCE_LABEL: Record = { config: "Key in config.toml", env: "Key from environment", + oauth: "Signed in", none: "No key configured", }; @@ -179,7 +180,9 @@ function ProviderRow({ > {provider.id} - {provider.type} + + {provider.host ?? provider.type} + {provider.models.length} model{provider.models.length === 1 ? "" : "s"} From a48d656b2f6d0ae8e8ec2885b8e70af75c3712e5 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:16:32 -0400 Subject: [PATCH 11/36] fix(vscode): filter the slash command menu by what was typed The menu matched a query against command descriptions as a loose subsequence, so the letters of "research" were found scattered through almost every description and the list came back unfiltered. Typing then looked like it did nothing but move a highlight that never left the first row. Matching is now ranked, with every way of matching a command name ordered ahead of a description match, and it stays forgiving about skipped letters and dropped separators. The selection also resets when the query changes, since a reordered list left it pointing at an unrelated command. --- apps/vscode/test/slash-menu.test.ts | 55 ++++++++++++++ .../src/components/SlashCommandMenu.tsx | 14 ++++ .../inputarea/hooks/slash-command-match.ts | 71 +++++++++++++++++++ .../inputarea/hooks/useSlashMenu.ts | 31 +++----- 4 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 apps/vscode/test/slash-menu.test.ts create mode 100644 apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts diff --git a/apps/vscode/test/slash-menu.test.ts b/apps/vscode/test/slash-menu.test.ts new file mode 100644 index 00000000..7b67e52e --- /dev/null +++ b/apps/vscode/test/slash-menu.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { + NO_MATCH, + rankSlashCommands, + scoreCommand, +} from "../webview-ui/src/components/inputarea/hooks/slash-command-match"; + +function command(name: string, description = ""): { name: string; description: string; aliases: string[] } { + return { name, description, aliases: [] }; +} + +const RESEARCH_SKILL = command("skill:research-writing", "Write research reports."); +const CUSTOM_THEME = command( + "custom-theme", + // Its letters spell "research" in order, which is exactly what the old + // subsequence match over descriptions matched on. + "Create or edit a pythinker-code custom color theme — a JSON file of color tokens, then reload the chat.", +); + +function rank(commands: readonly ReturnType[], query: string): string[] { + return rankSlashCommands(commands, query).map((entry) => entry.name); +} + +describe("scoreCommand", () => { + it("does not match a command whose description merely contains the query's letters", () => { + // The old subsequence match over descriptions let "research" through on + // "Create or edit ...", which made the menu look unfiltered. + expect(scoreCommand(CUSTOM_THEME, "research")).toBe(NO_MATCH); + }); + + it("finds a namespaced skill by the part the user actually types", () => { + expect(rank([CUSTOM_THEME, RESEARCH_SKILL], "research")).toEqual(["skill:research-writing"]); + }); + + it("ranks a name prefix above a name that only contains the query", () => { + expect(rank([command("sub-skill"), command("skills")], "skill")).toEqual(["skills", "sub-skill"]); + }); + + it("stays forgiving about dropped separators and skipped letters", () => { + expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "researchwriting"))).toBe(true); + expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "reswrit"))).toBe(true); + }); + + it("still falls back to the description when nothing matches the name", () => { + expect(Number.isFinite(scoreCommand(CUSTOM_THEME, "color"))).toBe(true); + }); + + it("ranks every name match above a description match", () => { + expect(rank([CUSTOM_THEME, command("theme-picker", "Nothing to see.")], "theme")).toEqual([ + "theme-picker", + "custom-theme", + ]); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx index 3a7d0028..374e4b4b 100644 --- a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx +++ b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx @@ -17,6 +17,20 @@ function highlightMatch(text: string, query: string): React.ReactNode { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); + + // The common case is a contiguous match; highlighting it as one run reads far + // better than scattering bold letters across the whole name. + const at = lowerText.indexOf(lowerQuery); + if (at !== -1) { + return ( + <> + {text.slice(0, at)} + {text.slice(at, at + query.length)} + {text.slice(at + query.length)} + + ); + } + const parts: React.ReactNode[] = []; let lastIdx = 0; let qi = 0; diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts new file mode 100644 index 00000000..b802a04e --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts @@ -0,0 +1,71 @@ +import type { SlashCommandInfo } from "shared/legacy-sdk"; + +export const NO_MATCH = Number.POSITIVE_INFINITY; + +function isSubsequence(text: string, query: string): boolean { + let qi = 0; + for (let i = 0; i < text.length && qi < query.length; i++) { + if (text[i] === query[qi]) { + qi++; + } + } + return qi === query.length; +} + +/** Separators are noise while typing: `researchwriting` should still find `skill:research-writing`. */ +function letters(text: string): string { + return text.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); +} + +function matchesAnyWordPrefix(text: string, query: string): boolean { + return text + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => word.startsWith(query)); +} + +/** + * Lower is a better match, `NO_MATCH` means the command is filtered out. Every + * way of matching the name outranks the description: the name is what the user + * is typing, and matching a long description loosely lets nearly every command + * through — which is what made the menu look unfiltered. + */ +export function scoreCommand(command: SlashCommandInfo, query: string): number { + const name = command.name.toLowerCase(); + const q = query.toLowerCase(); + if (name.startsWith(q)) { + return 0; + } + // A namespaced skill (`skill:research-writing`) should also match on the part + // the user is actually thinking of, not only on its full prefix. + if (matchesAnyWordPrefix(name, q)) { + return 1; + } + if (name.includes(q)) { + return 2; + } + // Forgiving tier: skipped letters and dropped separators still match. + if (isSubsequence(letters(name), letters(q))) { + return 3; + } + const description = command.description; + if (matchesAnyWordPrefix(description, q)) { + return 4; + } + return description.toLowerCase().includes(q) ? 5 : NO_MATCH; +} + +/** Commands that match `query`, best match first. An empty query keeps the original order. */ +export function rankSlashCommands( + commands: readonly SlashCommandInfo[], + query: string, +): SlashCommandInfo[] { + if (!query) { + return [...commands]; + } + return commands + .map((command) => ({ command, score: scoreCommand(command, query) })) + .filter((entry) => entry.score !== NO_MATCH) + .toSorted((left, right) => left.score - right.score) + .map((entry) => entry.command); +} diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts index c5661974..39324b1a 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts @@ -1,6 +1,7 @@ -import { useMemo, useState, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSettingsStore } from "@/stores"; import type { SlashCommandInfo } from "shared/legacy-sdk"; +import { rankSlashCommands } from "./slash-command-match"; interface ActiveToken { trigger: "/" | "@"; @@ -8,21 +9,6 @@ interface ActiveToken { query: string; } -function fuzzyMatch(text: string, query: string): boolean { - if (!query) { - return true; - } - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - let qi = 0; - for (let i = 0; i < lowerText.length && qi < lowerQuery.length; i++) { - if (lowerText[i] === lowerQuery[qi]) { - qi++; - } - } - return qi === lowerQuery.length; -} - export function findActiveToken(text: string, cursorPos: number): ActiveToken | null { const beforeCursor = text.slice(0, cursorPos); const lastSpace = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n"), beforeCursor.lastIndexOf("\t"), -1); @@ -56,17 +42,20 @@ export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: ( if (!showSlashMenu) { return []; } - const q = activeToken.query; - if (!q) { - return slashCommands; - } - return slashCommands.filter((cmd) => fuzzyMatch(cmd.name, q) || fuzzyMatch(cmd.description, q)); + return rankSlashCommands(slashCommands, activeToken.query); }, [showSlashMenu, activeToken?.query, slashCommands]); const resetSlashMenu = useCallback(() => { setSelectedIndex(0); }, []); + // Every keystroke reorders the list, so a selection carried over from the + // previous query points at an unrelated command — or past the end of the list. + const query = showSlashMenu ? activeToken.query : ""; + useEffect(() => { + setSelectedIndex(0); + }, [query]); + const handleSlashMenuKey = useCallback( (e: React.KeyboardEvent): boolean => { if (!showSlashMenu || filteredCommands.length === 0) { From 164af649805533de09dbc2834d3bc89de20d1500 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:30:57 -0400 Subject: [PATCH 12/36] refactor: name the managed provider after the platform serving it The managed OAuth provider authenticates against auth.kimi.com and serves its models from api.kimi.com, but it was registered as `managed:pythinker-code`. In a client that talks to several providers that reads as a first-party service rather than the one it actually is, and the models it installed carried a `pythinker-code/` prefix that said the same thing. It is now `managed:kimi-code`, with `kimi-code/*` model aliases and credentials under `oauth/kimi-code`. The provider name also stops being redeclared as a bare literal in four places and comes from the single exported constant instead. Existing configs are not rewritten: signing in again provisions the provider under its current name. --- .changeset/managed-provider-kimi-code.md | 8 + apps/pythinker-code/src/cli/sub/provider.ts | 6 +- apps/pythinker-code/src/cli/telemetry.ts | 6 +- apps/pythinker-code/src/constant/app.ts | 2 +- apps/pythinker-code/src/tui/commands/auth.ts | 14 +- .../src/tui/commands/prompts.ts | 6 +- .../src/tui/commands/provider.ts | 4 +- .../tui/components/dialogs/model-selector.ts | 2 +- .../components/dialogs/platform-selector.ts | 4 +- .../src/tui/utils/refresh-providers.ts | 44 +-- apps/pythinker-code/test/cli/export.test.ts | 2 +- apps/pythinker-code/test/cli/provider.test.ts | 8 +- .../test/cli/run-prompt.test.ts | 2 +- .../pythinker-code/test/cli/run-shell.test.ts | 2 +- .../pythinker-code/test/cli/telemetry.test.ts | 2 +- .../test/migration/migration-screen.test.ts | 2 +- .../components/dialogs/model-selector.test.ts | 2 +- .../dialogs/platform-selector.test.ts | 2 +- .../dialogs/tabbed-model-selector.test.ts | 6 +- .../components/messages/status-panel.test.ts | 2 +- .../tui/pythinker-tui-message-flow.test.ts | 38 +- .../test/tui/pythinker-tui-startup.test.ts | 62 ++-- .../test/tui/utils/refresh-providers.test.ts | 50 +-- .../test/tui/utils/thinking-levels.test.ts | 2 +- .../src/components/SettingsDialog.vue | 2 +- apps/vscode/test/bridge-handler.test.ts | 2 +- apps/vscode/test/settings-store.test.ts | 12 +- .../webview-ui/src/stores/settings.store.ts | 6 +- docs/configuration/config-files.md | 4 +- packages/agent-core/src/rpc/core-impl.ts | 10 +- .../src/services/auth/managedAuth.ts | 30 +- .../authSummary/authSummaryService.ts | 9 +- .../modelCatalog/modelCatalogService.ts | 38 +- .../agent-core/src/services/oauth/oauth.ts | 4 +- .../src/services/oauth/oauthService.ts | 10 +- .../test/agent/compaction/full.test.ts | 6 +- packages/agent-core/test/agent/turn.test.ts | 8 +- .../agent-core/test/config/configs.test.ts | 28 +- .../test/harness/model-alias-session.test.ts | 6 +- .../test/harness/plan-mode-session.test.ts | 4 +- .../test/harness/runtime-provider.test.ts | 28 +- .../agent-core/test/harness/runtime.test.ts | 2 +- packages/agent-core/test/loop/retry.test.ts | 8 +- .../agent-core/test/rpc/plugins-rpc.test.ts | 4 +- .../test/services/coreProcessService.test.ts | 2 +- .../services/model-catalog-service.test.ts | 20 +- .../test/services/oauth-service.test.ts | 10 +- .../test/steps/config.test.ts | 36 +- .../examples/pythinker-harness-auth-smoke.ts | 12 +- .../pythinker-harness-config-smoke.ts | 16 +- packages/node-sdk/src/auth.ts | 30 +- .../src/pythinker-code-model-provider.ts | 22 +- packages/node-sdk/test/auth-facade.test.ts | 172 ++++----- .../test/runtime-provider-oauth.test.ts | 16 +- .../node-sdk/test/session-set-model.test.ts | 8 +- ...ker-oauth-smoke.ts => kimi-oauth-smoke.ts} | 34 +- packages/oauth/package.json | 2 +- packages/oauth/src/constants.ts | 8 +- packages/oauth/src/custom-registry.ts | 12 +- packages/oauth/src/index.ts | 70 ++-- packages/oauth/src/managed-feedback.ts | 8 +- ...pythinker-code.ts => managed-kimi-code.ts} | 254 ++++++------- packages/oauth/src/managed-usage.ts | 20 +- packages/oauth/src/open-platform.ts | 30 +- packages/oauth/src/openai-codex-oauth.ts | 22 +- packages/oauth/src/toolkit.ts | 68 ++-- packages/oauth/src/types.ts | 2 +- packages/oauth/test/custom-registry.test.ts | 24 +- packages/oauth/test/managed-feedback.test.ts | 8 +- ...code.test.ts => managed-kimi-code.test.ts} | 338 +++++++++--------- packages/oauth/test/managed-usage.test.ts | 18 +- packages/oauth/test/open-platform.test.ts | 14 +- .../oauth/test/openai-codex-oauth.test.ts | 6 +- packages/oauth/test/toolkit.test.ts | 86 ++--- packages/server/test/auth.e2e.test.ts | 10 +- .../server/test/model-catalog.e2e.test.ts | 4 +- packages/server/test/oauth.e2e.test.ts | 10 +- 77 files changed, 949 insertions(+), 942 deletions(-) create mode 100644 .changeset/managed-provider-kimi-code.md rename packages/oauth/examples/{pythinker-oauth-smoke.ts => kimi-oauth-smoke.ts} (76%) rename packages/oauth/src/{managed-pythinker-code.ts => managed-kimi-code.ts} (69%) rename packages/oauth/test/{managed-pythinker-code.test.ts => managed-kimi-code.test.ts} (74%) diff --git a/.changeset/managed-provider-kimi-code.md b/.changeset/managed-provider-kimi-code.md new file mode 100644 index 00000000..d48b9c0f --- /dev/null +++ b/.changeset/managed-provider-kimi-code.md @@ -0,0 +1,8 @@ +--- +"@pythoughts/pythinker-code": minor +"@pythoughts/pythinker-code-sdk": minor +--- + +Name the managed OAuth provider after the platform that serves it. It is reached over `auth.kimi.com` and `api.kimi.com`, but it was registered as `managed:pythinker-code`, which read as a first-party service in a client that talks to several providers. The provider id is now `managed:kimi-code`, its models are aliased `kimi-code/*`, and its credentials are stored under `oauth/kimi-code`. + +This is a breaking change for an existing config: the previous entries are not rewritten, so run `pythinker login` once to provision the managed provider under its current name, then remove the stale `managed:pythinker-code` entry. diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 3a2be212..33a8c440 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -17,7 +17,7 @@ import { CustomRegistryApiError, fetchCustomRegistry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { catalogConnectionWire, @@ -517,8 +517,8 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string return undefined; } -function asManaged(config: PythinkerConfig): ManagedPythinkerConfigShape { - return config as unknown as ManagedPythinkerConfigShape; +function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; } function providerSourceLabel(provider: PythinkerConfig['providers'][string]): string { diff --git a/apps/pythinker-code/src/cli/telemetry.ts b/apps/pythinker-code/src/cli/telemetry.ts index 4111050b..8fcb2d45 100644 --- a/apps/pythinker-code/src/cli/telemetry.ts +++ b/apps/pythinker-code/src/cli/telemetry.ts @@ -1,4 +1,4 @@ -import { createPythinkerDeviceId, PYTHINKER_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; +import { createPythinkerDeviceId, KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { PythinkerAuthFacade, loadRuntimeConfigSafe, @@ -55,7 +55,7 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, getAccessToken: async () => - (await options.harness.auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, + (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); if (options.bootstrap.firstLaunch) { options.harness.track('first_launch'); @@ -102,7 +102,7 @@ export function initializeServerTelemetry( version: options.version, uiMode: WEB_UI_MODE, model: config.defaultModel, - getAccessToken: async () => (await auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, + getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); return { diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 89a88ecb..5a1e0dc7 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -34,7 +34,7 @@ export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; // Managed Pythinker auth provider key shared with OAuth/SDK config. -export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:pythinker-code'; +export { KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index e9c81c49..44627f09 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -10,8 +10,8 @@ import { OpenAICodexApiError, OpenPlatformApiError, runOpenAICodexOAuthFlow, - type ManagedPythinkerCodeModelInfo, - type ManagedPythinkerConfigShape, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, type OpenPlatformDefinition, } from '@pythoughts/pythinker-code-oauth'; import { @@ -54,7 +54,7 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise return; } - if (platformId === 'pythinker-code') { + if (platformId === 'kimi-code') { await handlePythinkerCodeOAuthLogin(host); return; } @@ -156,7 +156,7 @@ async function handleOpenPlatformLogin( }; host.cancelInFlight = cancelLogin; - let models: ManagedPythinkerCodeModelInfo[]; + let models: ManagedKimiCodeModelInfo[]; try { models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); models = filterModelsByPrefix(models, platform); @@ -193,7 +193,7 @@ async function handleOpenPlatformLogin( } const config = await host.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedPythinkerConfigShape, { + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { platform, models, selectedModel: selection.model, @@ -358,7 +358,7 @@ async function handleOpenAICodexOAuthLogin(host: SlashCommandHost): Promise { +): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = { diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index 7e0d5073..ff1dbde5 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -2,7 +2,7 @@ import { applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { CatalogFetchError, @@ -212,7 +212,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise try { const config = await host.harness.getConfig(); applyCustomRegistryEntries( - config as unknown as ManagedPythinkerConfigShape, + config as unknown as ManagedKimiConfigShape, entries, source, ); diff --git a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts index f8b4ce90..ef44051f 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts @@ -58,7 +58,7 @@ export function modelDisplayName(alias: string, model: ModelAlias | undefined): } export function providerDisplayName(provider: string): string { - if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return 'Pythinker'; + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return 'Kimi'; if (provider.startsWith('managed:')) return provider.slice('managed:'.length); return provider; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts index f450ec10..e546d197 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts @@ -44,9 +44,9 @@ function buildPlatformOptions(catalog: Catalog): readonly ChoiceOption[] { label: OPENAI_CODEX_OAUTH_LOGIN.name, description: 'OAuth', }, - { value: 'pythinker-code', label: 'Pythinker (OAuth)', description: 'OAuth' }, + { value: 'kimi-code', label: 'Kimi (OAuth)', description: 'OAuth' }, ]; - const seen = new Set(['pythinker-code', OPENAI_CODEX_OAUTH_LOGIN.id]); + const seen = new Set(['kimi-code', OPENAI_CODEX_OAUTH_LOGIN.id]); for (const featured of FEATURED_CATALOG_PROVIDERS) { const entry = catalog[featured.id]; diff --git a/apps/pythinker-code/src/tui/utils/refresh-providers.ts b/apps/pythinker-code/src/tui/utils/refresh-providers.ts index f273fd8e..50a45303 100644 --- a/apps/pythinker-code/src/tui/utils/refresh-providers.ts +++ b/apps/pythinker-code/src/tui/utils/refresh-providers.ts @@ -1,22 +1,22 @@ import { - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, OPENAI_CODEX_PROVIDER_ID, - applyManagedPythinkerCodeConfig, + applyManagedKimiCodeConfig, applyOpenAICodexOAuthConfig, applyOpenPlatformConfig, applyCustomRegistryProvider, fetchCustomRegistry, - fetchManagedPythinkerCodeModels, + fetchManagedKimiCodeModels, fetchOpenAICodexModels, fetchOpenPlatformModels, filterModelsByPrefix, getOpenPlatformById, isOpenPlatformId, removeCustomRegistryProvider, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeRuntimeAuth, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { applyCatalogProvider, @@ -116,8 +116,8 @@ async function fetchCustomRegistryFromSources( throw new Error('No custom registry sources configured.'); } -function asManaged(config: PythinkerConfig): ManagedPythinkerConfigShape { - return config as unknown as ManagedPythinkerConfigShape; +function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; } function collectModelIdsForAliases(config: PythinkerConfig, aliasKeys: ReadonlySet): Set { @@ -341,25 +341,25 @@ export async function refreshAllProviderModels( // ------------------------------------------------------------------------- // 1. Managed Pythinker Code (OAuth) // ------------------------------------------------------------------------- - const managedProvider = config.providers[PYTHINKER_CODE_PROVIDER_NAME]; + const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; if ( managedProvider !== undefined && managedProvider.type === 'pythinker' && managedProvider.oauth !== undefined ) { try { - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl: managedProvider.baseUrl, configuredOAuthRef: managedProvider.oauth, }); - const accessToken = await host.resolveOAuthToken(PYTHINKER_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedPythinkerCodeModels({ + const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); + const models = await fetchManagedKimiCodeModels({ accessToken, baseUrl: auth.baseUrl, }); if (models.length > 0) { const next = structuredClone(config); - applyManagedPythinkerCodeConfig(asManaged(next), { + applyManagedKimiCodeConfig(asManaged(next), { models, baseUrl: auth.baseUrl, oauthKey: auth.oauthRef.key, @@ -369,25 +369,25 @@ export async function refreshAllProviderModels( const refreshedAliasKeys = providerRefreshAliasKeys( config, next, - PYTHINKER_CODE_PROVIDER_NAME, - `${PYTHINKER_CODE_PLATFORM_ID}/`, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, ); restoreProviderAliases( next, - preserveUserProviderAliases(config, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys), + preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - if (providerModelsEqual(config, next, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(PYTHINKER_CODE_PROVIDER_NAME); + if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - await host.removeProvider(PYTHINKER_CODE_PROVIDER_NAME); + await host.removeProvider(KIMI_CODE_PROVIDER_NAME); config = await host.setConfig({ providers: next.providers, models: next.models, @@ -395,7 +395,7 @@ export async function refreshAllProviderModels( defaultThinking: next.defaultThinking, }); changed.push({ - providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerId: KIMI_CODE_PROVIDER_NAME, providerName: PRODUCT_NAME, added, removed, @@ -404,7 +404,7 @@ export async function refreshAllProviderModels( } } catch (error) { failed.push({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, reason: error instanceof Error ? error.message : String(error), }); } @@ -688,7 +688,7 @@ export async function refreshAllProviderModels( } >(); for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === PYTHINKER_CODE_PROVIDER_NAME) continue; + if (providerId === KIMI_CODE_PROVIDER_NAME) continue; if (providerId === OPENAI_CODEX_PROVIDER_ID) continue; if (isOpenPlatformId(providerId)) continue; const source = readCustomRegistrySource(providerConfig); diff --git a/apps/pythinker-code/test/cli/export.test.ts b/apps/pythinker-code/test/cli/export.test.ts index 570fb592..9cf1bcf8 100644 --- a/apps/pythinker-code/test/cli/export.test.ts +++ b/apps/pythinker-code/test/cli/export.test.ts @@ -80,7 +80,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index 32df66a5..503ce52c 100644 --- a/apps/pythinker-code/test/cli/provider.test.ts +++ b/apps/pythinker-code/test/cli/provider.test.ts @@ -495,10 +495,10 @@ describe('pythinker provider list', () => { apiKey: 'k', source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, }, - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, }, @@ -533,7 +533,7 @@ describe('pythinker provider list', () => { const out = stdout.join(''); expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); - expect(out).toMatch(/managed:pythinker-code\s+type=pythinker\s+models=0\s+source=oauth/); + expect(out).toMatch(/managed:kimi-code\s+type=pythinker\s+models=0\s+source=oauth/); expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); expect(out).toContain('Default model: kohub/a'); }); @@ -559,7 +559,7 @@ describe('pythinker provider list', () => { }; expect(Object.keys(parsed.providers).toSorted()).toEqual([ 'kohub', - 'managed:pythinker-code', + 'managed:kimi-code', 'manual', ]); expect(Object.keys(parsed.models)).toContain('kohub/a'); diff --git a/apps/pythinker-code/test/cli/run-prompt.test.ts b/apps/pythinker-code/test/cli/run-prompt.test.ts index e9a77a67..95ee74fb 100644 --- a/apps/pythinker-code/test/cli/run-prompt.test.ts +++ b/apps/pythinker-code/test/cli/run-prompt.test.ts @@ -118,7 +118,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index 70d695b3..2b6aa6e9 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -111,7 +111,7 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/telemetry.test.ts b/apps/pythinker-code/test/cli/telemetry.test.ts index 3edd6910..50d2ebe8 100644 --- a/apps/pythinker-code/test/cli/telemetry.test.ts +++ b/apps/pythinker-code/test/cli/telemetry.test.ts @@ -31,7 +31,7 @@ vi.mock('@pythoughts/pythinker-telemetry', () => ({ vi.mock('@pythoughts/pythinker-code-oauth', () => ({ createPythinkerDeviceId: mocks.createPythinkerDeviceId, - PYTHINKER_CODE_PROVIDER_NAME: 'managed:pythinker-code', + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', })); vi.mock('@pythoughts/pythinker-code-sdk', async (importOriginal) => { diff --git a/apps/pythinker-code/test/migration/migration-screen.test.ts b/apps/pythinker-code/test/migration/migration-screen.test.ts index 68fd7372..66a48495 100644 --- a/apps/pythinker-code/test/migration/migration-screen.test.ts +++ b/apps/pythinker-code/test/migration/migration-screen.test.ts @@ -455,7 +455,7 @@ describe('MigrationScreenComponent — result phase', () => { migratedHooks: 0, droppedHooks: 0, siblingContents: { - providers: ['openai', 'managed:pythinker-code'], + providers: ['openai', 'managed:kimi-code'], models: ['gpt4'], hooks: 3, }, diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index 1e28fc24..d327df94 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -20,7 +20,7 @@ function model( supportEfforts?: string[], ): ModelAlias { return { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: displayName.toLowerCase().replaceAll(' ', '-'), maxContextSize: 200_000, displayName, diff --git a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts index dddcd722..41c4081a 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts @@ -59,7 +59,7 @@ describe('PlatformSelectorComponent', () => { const output = rendered(component); expect(output.indexOf('OpenAI Codex (OAuth)')).toBeLessThan( - output.indexOf('Pythinker (OAuth)'), + output.indexOf('Kimi (OAuth)'), ); expect(output).toContain('DeepSeek API'); expect(output).toContain('GLM Coding Plan'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index ec20c3b3..ad82708f 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -34,7 +34,7 @@ function make(): { const onSelect = vi.fn(); const component = new TabbedModelSelectorComponent({ models: { - k2: model('Kimi K2', 'managed:pythinker-code'), + k2: model('Kimi K2', 'managed:kimi-code'), gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', @@ -72,7 +72,7 @@ describe('TabbedModelSelectorComponent', () => { it('opens on the current model provider by default', () => { const { component } = make(); const out = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:pythinker-code'); + expect(component.activeTabId()).toBe('managed:kimi-code'); expect(out).toContain('Kimi K2'); expect(out).not.toContain('GPT-5'); expect(out).toMatch(/❯ Kimi K2\s+Pythinker ← current/u); @@ -135,7 +135,7 @@ describe('TabbedModelSelectorComponent', () => { component.handleInput(RIGHT); // off -> low for k2 const output = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:pythinker-code'); + expect(component.activeTabId()).toBe('managed:kimi-code'); expect(output).toContain('Kimi K2'); component.handleInput('\r'); diff --git a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts index b2ff141b..4950addb 100644 --- a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts @@ -22,7 +22,7 @@ describe('status panel report lines', () => { maxContextTokens: 10000, availableModels: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 10000, displayName: 'Kimi K2', diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index ae376e08..5e19a923 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1221,7 +1221,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -1254,7 +1254,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -1285,7 +1285,7 @@ command = "vim" k2: { model: 'pythoughts-v1', maxContextSize: 100, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }, }, })), @@ -5196,14 +5196,14 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], }, turbo: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-turbo', maxContextSize: 100, displayName: 'Kimi Turbo', @@ -5255,7 +5255,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5294,7 +5294,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5326,7 +5326,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5354,7 +5354,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5390,7 +5390,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5422,7 +5422,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5455,7 +5455,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5504,7 +5504,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { plain: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-plain', maxContextSize: 100, displayName: 'Plain Model', @@ -5532,7 +5532,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Old Kimi K2', @@ -5552,7 +5552,7 @@ command = "vim" tui.setAppState({ availableModels: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Fresh Kimi K2', @@ -5563,7 +5563,7 @@ command = "vim" resolve(); }; }); - return { changed: ['managed:pythinker-code'], unchanged: [], failed: [] }; + return { changed: ['managed:kimi-code'], unchanged: [], failed: [] }; }); ( tui.authFlow as unknown as { @@ -5599,7 +5599,7 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-k2', maxContextSize: 100, displayName: 'Kimi K2', @@ -5722,14 +5722,14 @@ command = "vim" const { driver } = await makeDriver(); const selection = runModelSelector(driver as any, { alpha: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-alpha', maxContextSize: 100, displayName: 'Pythinker Alpha', capabilities: ['thinking'], }, turbo: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-turbo', maxContextSize: 100, displayName: 'Kimi Turbo', diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index dcf60215..2f98f0da 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -296,7 +296,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool } function loginRequiredError(): Error & { readonly code: string } { - return Object.assign(new Error('OAuth provider "managed:pythinker-code" requires login.'), { + return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { code: 'auth.login_required', }); } @@ -905,13 +905,13 @@ describe('PythinkerTUI startup', () => { it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: 'pythinker-code/k2.5' })); + const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', - model: 'pythinker-code/k2.5', + model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, }); @@ -938,13 +938,13 @@ describe('PythinkerTUI startup', () => { }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: 'pythinker-code/k2.5' }), + makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith('pythinker-code/k2.5'); - expect(driver.state.appState.model).toBe('pythinker-code/k2.5'); + expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); }); it('enters picker startup for bare --session without creating a session', async () => { @@ -1517,7 +1517,7 @@ describe('PythinkerTUI startup', () => { }); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1572,7 +1572,7 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1606,7 +1606,7 @@ describe('PythinkerTUI startup', () => { expect(driver.state.appState.thinkingLevel).toBe('off'); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); @@ -1619,7 +1619,7 @@ describe('PythinkerTUI startup', () => { maxContextTokens: 100, }); expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', already_logged_in: false, }); }); @@ -1629,7 +1629,7 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1642,20 +1642,20 @@ describe('PythinkerTUI startup', () => { harness.track.mockClear(); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:pythinker-code', + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), }), ); expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', already_logged_in: true, }); }); @@ -1680,13 +1680,13 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'pythinker-code', + platformId: 'kimi-code', catalog: {}, }); await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:pythinker-code', + 'managed:kimi-code', expect.objectContaining({ signal: expect.any(AbortSignal), onDeviceCode: expect.any(Function), @@ -1695,7 +1695,7 @@ describe('PythinkerTUI startup', () => { expect(warn).toHaveBeenCalledWith( 'login failed', expect.objectContaining({ - providerName: 'managed:pythinker-code', + providerName: 'managed:kimi-code', alreadyLoggedIn: false, sessionId: 'ses-1', error: expect.objectContaining({ @@ -1880,13 +1880,13 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, - providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + providers: { 'managed:kimi-code': { type: 'pythinker' } }, })), auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1898,17 +1898,17 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); expect(session.close).toHaveBeenCalledOnce(); expect(driver.state.appState).toMatchObject({ sessionId: '', model: '', sessionTitle: null, }); - expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:pythinker-code' }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); }); it('keeps the active session when logging out a different provider', async () => { @@ -1917,17 +1917,17 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, providers: { - 'managed:pythinker-code': { type: 'pythinker' }, + 'managed:kimi-code': { type: 'pythinker' }, openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1957,15 +1957,15 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:pythinker-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, }, - providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + providers: { 'managed:kimi-code': { type: 'pythinker' } }, })), auth: { // Token gone (e.g. credentials file deleted) but the managed entry // is still sitting in config.providers. status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:pythinker-code', hasToken: false }], + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1976,10 +1976,10 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); await handleLogoutCommand(driver as any); - expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); }); it('starts TUI without replaying when --continue needs OAuth login', async () => { diff --git a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts index b700dafe..d57c7ef1 100644 --- a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - PYTHINKER_CODE_PROVIDER_NAME, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, + KIMI_CODE_PROVIDER_NAME, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, } from '@pythoughts/pythinker-code-oauth'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -73,14 +73,14 @@ describe('refreshAllProviderModels', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthRef = resolvePythinkerCodeOAuthRef({ + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthRef = resolveKimiCodeOAuthRef({ oauthHost: envOauthHost, baseUrl: envBaseUrl, }); const config: PythinkerConfig = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', baseUrl: configuredBaseUrl, apiKey: '', @@ -92,14 +92,14 @@ describe('refreshAllProviderModels', () => { }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', telemetry: true, }; vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); @@ -135,9 +135,9 @@ describe('refreshAllProviderModels', () => { }); expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual([PYTHINKER_CODE_PROVIDER_NAME]); + expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(resolveOAuthToken).toHaveBeenCalledWith(PYTHINKER_CODE_PROVIDER_NAME, envOauthRef); + expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); }); it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { @@ -145,13 +145,13 @@ describe('refreshAllProviderModels', () => { const registryUrl = 'https://registry.example.test/v1/models/api.json'; const config: PythinkerConfig = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', baseUrl, apiKey: '', oauth: { storage: 'file', - key: resolvePythinkerCodeOAuthKey({ baseUrl }), + key: resolveKimiCodeOAuthKey({ baseUrl }), }, }, custom: { @@ -162,8 +162,8 @@ describe('refreshAllProviderModels', () => { }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], @@ -177,7 +177,7 @@ describe('refreshAllProviderModels', () => { displayName: 'Custom M1', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', telemetry: true, }; const host = makeRefreshHost(config); @@ -215,7 +215,7 @@ describe('refreshAllProviderModels', () => { expect(result.failed).toEqual([]); expect(result.changed).toEqual([ { - providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerId: KIMI_CODE_PROVIDER_NAME, providerName: 'Pythinker', added: 0, removed: 0, @@ -223,7 +223,7 @@ describe('refreshAllProviderModels', () => { ]); expect(result.unchanged).toEqual([]); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.current().models?.['pythinker-code/pythinker-for-coding']?.displayName).toBe('Fresh Pythinker'); + expect(host.current().models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Fresh Pythinker'); expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); }); @@ -1111,21 +1111,21 @@ max_context_size = 64000 it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { const host = makeRefreshHost({ providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { - 'pythinker-code/pythinker-deep-coder': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-deep-coder': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-deep-coder', maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], }, }, - defaultModel: 'pythinker-code/pythinker-deep-coder', + defaultModel: 'kimi-code/pythinker-deep-coder', defaultThinking: false, telemetry: true, } as unknown as PythinkerConfig); @@ -1157,12 +1157,12 @@ max_context_size = 64000 }); expect(result.failed).toEqual([]); - expect(host.current().models?.['pythinker-code/pythinker-deep-coder']?.capabilities).toEqual([ + expect(host.current().models?.['kimi-code/pythinker-deep-coder']?.capabilities).toEqual([ 'thinking', 'always_thinking', 'tool_use', ]); - expect(host.current().defaultModel).toBe('pythinker-code/pythinker-deep-coder'); + expect(host.current().defaultModel).toBe('kimi-code/pythinker-deep-coder'); expect(host.current().defaultThinking).toBe(true); }); }); diff --git a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts b/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts index 9ddc4de3..18fbbb1e 100644 --- a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts +++ b/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts @@ -10,7 +10,7 @@ import { function model(overrides: Partial = {}): ModelAlias { return { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'k2', maxContextSize: 200_000, capabilities: ['thinking'], diff --git a/apps/pythinker-web/src/components/SettingsDialog.vue b/apps/pythinker-web/src/components/SettingsDialog.vue index 95066c6c..c0b35027 100644 --- a/apps/pythinker-web/src/components/SettingsDialog.vue +++ b/apps/pythinker-web/src/components/SettingsDialog.vue @@ -264,7 +264,7 @@ function setTab(tab: SettingsTab): void {

{{ t('settings.account') }}

- {{ authReady ? 'managed:pythinker-code' : t('sidebar.notSignedIn') }} + {{ authReady ? 'managed:kimi-code' : t('sidebar.notSignedIn') }} {{ accountModel }}
diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index b5b18c98..40330c30 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -432,7 +432,7 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => describe("Webview config saves (thinking effort persistence parity with the TUI)", () => { const effortModel = { - provider: "managed:pythinker-code", + provider: "managed:kimi-code", model: "reasoning", supportEfforts: ["low", "high", "max"], defaultEffort: "high", diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index bf4d07bb..c2da2778 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -39,16 +39,16 @@ import { import { useChatStore } from "../webview-ui/src/stores/chat.store"; const MODELS = [ - { id: "plain", name: "Plain", provider: "managed:pythinker-code", capabilities: [] }, + { id: "plain", name: "Plain", provider: "managed:kimi-code", capabilities: [] }, { id: "reasoning", name: "Reasoning", - provider: "managed:pythinker-code", + provider: "managed:kimi-code", capabilities: ["thinking"], support_efforts: ["low", "high"], default_effort: "high", }, - { id: "always", name: "Always", provider: "managed:pythinker-code", capabilities: ["always_thinking"] }, + { id: "always", name: "Always", provider: "managed:kimi-code", capabilities: ["always_thinking"] }, ]; beforeEach(() => { @@ -145,7 +145,7 @@ describe("Webview model settings persistence", () => { describe("Webview model metadata", () => { it("keeps same-named models in separate provider groups", () => { const groups = groupModelsByProvider([ - { id: "kimi/shared", name: "Shared", provider: "managed:pythinker-code", capabilities: [] }, + { id: "kimi/shared", name: "Shared", provider: "managed:kimi-code", capabilities: [] }, { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, ]); @@ -155,7 +155,7 @@ describe("Webview model metadata", () => { models: group.models.map((model) => model.id), }))).toEqual([ { provider: "company-proxy", label: "company-proxy", models: ["proxy/shared"] }, - { provider: "managed:pythinker-code", label: "Pythinker Code", models: ["kimi/shared"] }, + { provider: "managed:kimi-code", label: "Kimi Code", models: ["kimi/shared"] }, ]); }); @@ -192,7 +192,7 @@ describe("Webview model metadata", () => { it("requires Kimi login when the default model uses the managed provider", () => { expect(requiresManagedProviderLogin([ - { id: "kimi/model", name: "Kimi", provider: "managed:pythinker-code", capabilities: [] }, + { id: "kimi/model", name: "Kimi", provider: "managed:kimi-code", capabilities: [] }, ], "kimi/model", false)).toBe(true); }); }); diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 5af520cb..9f57c5c9 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -5,7 +5,7 @@ import type { ExtensionConfig } from "shared/types"; import type { MCPServerConfig, ModelConfig, ThinkingMode, SlashCommandInfo } from "shared/legacy-sdk"; let settingsSaveRevision = 0; -const MANAGED_PYTHINKER_CODE_PROVIDER = "managed:pythinker-code"; +const MANAGED_KIMI_CODE_PROVIDER = "managed:kimi-code"; function saveConfigWithRollback( config: Parameters[0], @@ -47,7 +47,7 @@ export function getModelThinkingMode(model: ModelConfig): ThinkingMode { } export function providerDisplayName(provider: string): string { - if (provider === MANAGED_PYTHINKER_CODE_PROVIDER) return "Pythinker Code"; + if (provider === MANAGED_KIMI_CODE_PROVIDER) return "Kimi Code"; if (provider.startsWith("managed:")) return provider.slice("managed:".length); return provider; } @@ -84,7 +84,7 @@ export function requiresManagedProviderLogin( ): boolean { if (loggedIn) return false; const activeModel = getModelById(models, defaultModel ?? "") ?? models[0]; - return activeModel?.provider === MANAGED_PYTHINKER_CODE_PROVIDER; + return activeModel?.provider === MANAGED_KIMI_CODE_PROVIDER; } function defaultEffortForModel(model: ModelConfig, defaultThinking: boolean, configuredEffort?: string): string { diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index ccb2bb57..1117cc5c 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -30,13 +30,13 @@ default_plan_mode = false merge_all_available_skills = true telemetry = true -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" api_key = "" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index b346fc83..a4886de3 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -1,3 +1,4 @@ +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -132,7 +133,6 @@ import type { ResumedAgentState, ResumeSessionResult } from './resumed'; import type { SDKRPC } from './sdk-api'; import { proxyWithExtraPayload } from './types'; -const PYTHINKER_CODE_PROVIDER_NAME = 'managed:pythinker-code'; const PYTHINKER_CODE_BASE_URL_ENV = 'PYTHINKER_CODE_BASE_URL'; const PYTHINKER_CODE_OAUTH_HOST_ENV = 'PYTHINKER_CODE_OAUTH_HOST'; const PYTHINKER_OAUTH_HOST_ENV = 'PYTHINKER_OAUTH_HOST'; @@ -1114,7 +1114,7 @@ export class PythinkerCore implements PromisableMethods { } private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined { - const pluginServers = this.withManagedPythinkerPluginEnv(this.plugins.enabledMcpServers()); + const pluginServers = this.withManagedKimiPluginEnv(this.plugins.enabledMcpServers()); if (Object.keys(pluginServers).length === 0) return base; return { servers: { @@ -1124,7 +1124,7 @@ export class PythinkerCore implements PromisableMethods { }; } - private withManagedPythinkerPluginEnv( + private withManagedKimiPluginEnv( pluginServers: Record, ): Record { const managedEnv = this.managedPythinkerCodeEnvForPlugins(); @@ -1141,7 +1141,7 @@ export class PythinkerCore implements PromisableMethods { } private managedPythinkerCodeEnvForPlugins(): Record { - const provider = this.config.providers[PYTHINKER_CODE_PROVIDER_NAME]; + const provider = this.config.providers[KIMI_CODE_PROVIDER_NAME]; const envBaseUrl = process.env[PYTHINKER_CODE_BASE_URL_ENV]; const envOAuthHost = process.env[PYTHINKER_CODE_OAUTH_HOST_ENV] ?? process.env[PYTHINKER_OAUTH_HOST_ENV]; @@ -1345,7 +1345,7 @@ function serviceCredentials( apiKey, tokenProvider: service.oauth !== undefined - ? resolveOAuthTokenProvider?.(PYTHINKER_CODE_PROVIDER_NAME, service.oauth) + ? resolveOAuthTokenProvider?.(KIMI_CODE_PROVIDER_NAME, service.oauth) : undefined, customHeaders: service.customHeaders, }; diff --git a/packages/agent-core/src/services/auth/managedAuth.ts b/packages/agent-core/src/services/auth/managedAuth.ts index aa39a224..4e66acf7 100644 --- a/packages/agent-core/src/services/auth/managedAuth.ts +++ b/packages/agent-core/src/services/auth/managedAuth.ts @@ -2,20 +2,20 @@ import { readConfigFile, writeConfigFile } from '../../config'; import type { PythinkerConfig, OAuthRef } from '../../config'; import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; import { - applyManagedPythinkerCodeConfig, - applyManagedPythinkerCodeLogoutConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + applyManagedKimiCodeLogoutConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeLoginAuth, + resolveKimiCodeRuntimeAuth, type BearerTokenProvider, type PythinkerOAuthLoginOptions, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import type { IEnvironmentService } from '../environment/environment'; -type ServicesManagedConfig = PythinkerConfig & ManagedPythinkerConfigShape; +type ServicesManagedConfig = PythinkerConfig & ManagedKimiConfigShape; type ServicesAuthLoginOptions = Omit; @@ -59,18 +59,18 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { write: async (config) => { await writeConfigFile(options.configPath, config); }, - apply: applyManagedPythinkerCodeConfig, - remove: applyManagedPythinkerCodeLogoutConfig, + apply: applyManagedKimiCodeConfig, + remove: applyManagedKimiCodeLogoutConfig, }, }); } async login( - providerName: string | undefined = PYTHINKER_CODE_PROVIDER_NAME, + providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, options: ServicesAuthLoginOptions = {}, ): Promise { const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolvePythinkerCodeLoginAuth({ + const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, requestedBaseUrl: options.baseUrl, @@ -132,7 +132,7 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { readonly oauthRef?: OAuthRef | undefined; readonly baseUrl?: string | undefined; } { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const config = readConfigFile(this.options.configPath); const provider = config.providers[name]; return { @@ -146,7 +146,7 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { readonly baseUrl?: string | undefined; } { const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, }); @@ -156,11 +156,11 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { providerName: string | undefined, oauthRef?: OAuthRef | undefined, ): OAuthRef | undefined { - if ((providerName ?? PYTHINKER_CODE_PROVIDER_NAME) !== PYTHINKER_CODE_PROVIDER_NAME) { + if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) { return oauthRef; } const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index 653bb049..ff35e5cb 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -2,6 +2,7 @@ * `AuthSummaryService` — implementation of `IAuthSummaryService`. */ +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { Disposable, InstantiationType, registerSingleton } from '../../di'; import { resolveProviderApiKey, type PythinkerConfig } from '../../config'; import type { AuthSummary } from '@pythoughts/protocol'; @@ -15,8 +16,6 @@ import { AuthModelNotResolvedError, } from './authSummary'; -/** Wire name of the OAuth-managed provider (`@pythoughts/pythinker-code-oauth`'s `PYTHINKER_CODE_PROVIDER_NAME`). */ -const MANAGED_PROVIDER_NAME = 'managed:pythinker-code'; export class AuthSummaryService extends Disposable @@ -40,10 +39,10 @@ export class AuthSummaryService const default_model = nonEmpty(config.defaultModel); let managed_provider: AuthSummary['managed_provider'] = null; - if (providers[MANAGED_PROVIDER_NAME] !== undefined) { - const hasToken = await this._hasCachedToken(MANAGED_PROVIDER_NAME); + if (providers[KIMI_CODE_PROVIDER_NAME] !== undefined) { + const hasToken = await this._hasCachedToken(KIMI_CODE_PROVIDER_NAME); managed_provider = { - name: MANAGED_PROVIDER_NAME, + name: KIMI_CODE_PROVIDER_NAME, status: hasToken ? 'authenticated' : 'unauthenticated', }; } diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index f428ac11..16fd422d 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -12,12 +12,12 @@ import type { SetDefaultModelResponse, } from '@pythoughts/protocol'; import { - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, - applyManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - resolvePythinkerCodeRuntimeAuth, - type ManagedPythinkerConfigShape, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; @@ -101,32 +101,32 @@ export class ModelCatalogService const changed: RefreshOAuthProviderModelsResponse['changed'] = []; const unchanged: string[] = []; const failed: RefreshOAuthProviderModelsResponse['failed'] = []; - const provider = config.providers?.[PYTHINKER_CODE_PROVIDER_NAME]; + const provider = config.providers?.[KIMI_CODE_PROVIDER_NAME]; if (provider?.type !== 'pythinker' || provider.oauth === undefined) { return { changed, unchanged, failed }; } try { - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl: provider.baseUrl, configuredOAuthRef: provider.oauth, }); const tokenProvider = this._authFacade.resolveOAuthTokenProvider( - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, auth.oauthRef, ); if (tokenProvider === undefined) { throw new Error('OAuth token provider is not configured.'); } const token = await tokenProvider.getAccessToken(); - const models = await fetchManagedPythinkerCodeModels({ + const models = await fetchManagedKimiCodeModels({ accessToken: token, baseUrl: auth.baseUrl, }); if (models.length === 0) return { changed, unchanged, failed }; const next = structuredClone(config); - applyManagedPythinkerCodeConfig(next as unknown as ManagedPythinkerConfigShape, { + applyManagedKimiCodeConfig(next as unknown as ManagedKimiConfigShape, { models, baseUrl: auth.baseUrl, oauthKey: auth.oauthRef.key, @@ -136,24 +136,24 @@ export class ModelCatalogService const refreshedAliasKeys = providerRefreshAliasKeys( config, next, - PYTHINKER_CODE_PROVIDER_NAME, - `${PYTHINKER_CODE_PLATFORM_ID}/`, + KIMI_CODE_PROVIDER_NAME, + `${KIMI_CODE_PLATFORM_ID}/`, ); restoreProviderAliases( next, - preserveUserProviderAliases(config, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys), + preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); - if (providerModelsEqual(config, next, PYTHINKER_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(PYTHINKER_CODE_PROVIDER_NAME); + if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - await this.core.rpc.removePythinkerProvider({ providerId: PYTHINKER_CODE_PROVIDER_NAME }); + await this.core.rpc.removePythinkerProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); await this.core.rpc.setPythinkerConfig({ providers: next.providers, models: next.models, @@ -161,7 +161,7 @@ export class ModelCatalogService defaultThinking: next.defaultThinking, }); changed.push({ - provider_id: PYTHINKER_CODE_PROVIDER_NAME, + provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Pythinker Code', added, removed, @@ -169,7 +169,7 @@ export class ModelCatalogService } } catch (error) { failed.push({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, reason: error instanceof Error ? error.message : String(error), }); } diff --git a/packages/agent-core/src/services/oauth/oauth.ts b/packages/agent-core/src/services/oauth/oauth.ts index 948d843e..a919491f 100644 --- a/packages/agent-core/src/services/oauth/oauth.ts +++ b/packages/agent-core/src/services/oauth/oauth.ts @@ -19,7 +19,7 @@ * 3. Same-provider new flow superseding (→ 'cancelled') * * **Token + config** land via the toolkit's provisioning path: on success, - * the `managed:pythinker-code` provider + models entry are written to + * the `managed:kimi-code` provider + models entry are written to * `config.toml`, and the cached token is saved to credentials. Frontend * follow-up: hit `GET /v1/auth` to confirm `ready: true`. * @@ -75,7 +75,7 @@ export interface IOAuthService { /** * Kick off a device-code flow for `providerName` (default - * `'managed:pythinker-code'`). Requests the device authorization synchronously + * `'managed:kimi-code'`). Requests the device authorization synchronously * (1-2 round-trips to the OAuth host), starts background polling, and * returns the verification URLs + flow_id. * diff --git a/packages/agent-core/src/services/oauth/oauthService.ts b/packages/agent-core/src/services/oauth/oauthService.ts index 66631c88..7f789fc2 100644 --- a/packages/agent-core/src/services/oauth/oauthService.ts +++ b/packages/agent-core/src/services/oauth/oauthService.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap, InstantiationType, registerSingleton } from import type { IDisposable } from '../../di'; import { DeviceCodeTimeoutError, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, OAuthError, type DeviceAuthorization, } from '@pythoughts/pythinker-code-oauth'; @@ -94,7 +94,7 @@ export class OAuthService extends Disposable implements IOAuthService { } async startLogin(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Supersede any existing pending flow. const existing = this._flows.get(name); @@ -181,14 +181,14 @@ export class OAuthService extends Disposable implements IOAuthService { } getFlow(providerName?: string): OAuthFlowSnapshot | undefined { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const state = this._flows.get(name); if (state === undefined) return undefined; return this._toSnapshot(state); } async cancelLogin(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const state = this._flows.get(name); if (state === undefined) { // No flow at all → treat as "already cancelled" (idempotent). @@ -203,7 +203,7 @@ export class OAuthService extends Disposable implements IOAuthService { } async logout(providerName?: string): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Also cancel any in-flight flow so the next `GET /v1/auth` sees a clean // slate. const pending = this._flows.get(name); diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 072b97bf..951c0656 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -2126,15 +2126,15 @@ function oauthTestAgentOptions( initialConfig: { defaultModel: 'pythinker-code', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'vertexai', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index bd702815..f5a98508 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1221,7 +1221,7 @@ describe('Agent turn flow', () => { tokenCalls.push(options?.force); throw new PythinkerError( ErrorCodes.PROVIDER_CONNECTION_ERROR, - 'OAuth provider "managed:pythinker-code" failed to fetch an access token: fetch failed', + 'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed', ); }); const generate = vi.fn(); @@ -2024,15 +2024,15 @@ function oauthAgentOptions( initialConfig: { defaultModel: 'pythinker-code', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'vertexai', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, capabilities: capabilities === undefined ? undefined : [...capabilities], diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index c045c998..b452bad4 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -59,17 +59,17 @@ additional_dirs = ["~/shared-project", "/tmp/reference"] telemetry = false theme = "dark" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" api_key = "sk-file" custom_headers = { "X-Test" = "1" } -[providers."managed:pythinker-code".env] +[providers."managed:kimi-code".env] GOOGLE_CLOUD_PROJECT = "project-1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 capabilities = ["image_in", "thinking", "video_in"] @@ -140,7 +140,7 @@ describe('harness config TOML loader', () => { expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); expect(config.additionalDirs).toEqual(['~/shared-project', '/tmp/reference']); expect(config.telemetry).toBe(false); - expect(config.providers['managed:pythinker-code']).toMatchObject({ + expect(config.providers['managed:kimi-code']).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'sk-file', @@ -148,7 +148,7 @@ describe('harness config TOML loader', () => { customHeaders: { 'X-Test': '1' }, }); expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['image_in', 'thinking', 'video_in'], @@ -255,21 +255,21 @@ api_key_env_var = "DEEPSEEK_API_KEY" const dir = makeTempDir(); const configPath = join(dir, 'oauth-ref.toml'); const toml = ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } [services.pythoughts_search] base_url = "https://api.dev.example.test/coding/v1/search" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } `; const config = parseConfigString(toml, configPath); - expect(config.providers['managed:pythinker-code']?.oauth).toEqual({ + expect(config.providers['managed:kimi-code']?.oauth).toEqual({ storage: 'file', - key: 'oauth/pythinker-code-env-1234', + key: 'oauth/kimi-code-env-1234', oauthHost: 'https://auth.dev.example.test', }); expect(config.services?.pythoughtsSearch?.oauth?.oauthHost).toBe('https://auth.dev.example.test'); @@ -278,7 +278,7 @@ oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = const text = await readFile(configPath, 'utf-8'); expect(text).toContain('oauth_host = "https://auth.dev.example.test"'); const roundTripped = parseConfigString(text, configPath); - expect(roundTripped.providers['managed:pythinker-code']?.oauth?.oauthHost).toBe( + expect(roundTripped.providers['managed:kimi-code']?.oauth?.oauthHost).toBe( 'https://auth.dev.example.test', ); }); @@ -576,7 +576,7 @@ describe('harness config schema and patch merge', () => { const base = parseConfigString(COMPLETE_TOML); const merged = mergeConfigPatch(base, { providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { apiKey: 'sk-patched', baseUrl: undefined, }, @@ -591,14 +591,14 @@ describe('harness config schema and patch merge', () => { }, }); - expect(merged.providers['managed:pythinker-code']).toMatchObject({ + expect(merged.providers['managed:kimi-code']).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'sk-patched', env: { GOOGLE_CLOUD_PROJECT: 'project-1' }, }); expect(merged.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['tool_use'], diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 9a85d75b..1ab499d0 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -24,13 +24,13 @@ import { const CONFIG = ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 1000000 `; @@ -311,7 +311,7 @@ reason = "no rm" ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" diff --git a/packages/agent-core/test/harness/plan-mode-session.test.ts b/packages/agent-core/test/harness/plan-mode-session.test.ts index ee8d05ab..54bb0013 100644 --- a/packages/agent-core/test/harness/plan-mode-session.test.ts +++ b/packages/agent-core/test/harness/plan-mode-session.test.ts @@ -9,13 +9,13 @@ import { createRPC, PythinkerCore, type CoreAPI, type SDKAPI } from '../../src'; const BASE_CONFIG = ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "test-key" base_url = "https://api.example/v1" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 1000000 `; diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index aacc898e..abbb9026 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -34,7 +34,7 @@ function resolveRuntimeProvider(input: { const BASE_CONFIG: PythinkerConfig = { defaultModel: 'pythinker-code/pythinker-for-coding', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -42,7 +42,7 @@ const BASE_CONFIG: PythinkerConfig = { }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], @@ -115,11 +115,11 @@ describe('resolveRuntimeProvider model metadata', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }, @@ -140,7 +140,7 @@ describe('resolveRuntimeProvider model metadata', () => { ...BASE_CONFIG, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, @@ -212,7 +212,7 @@ describe('resolveRuntimeProvider model metadata', () => { ...BASE_CONFIG, models: { broken: { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', capabilities: ['thinking'], }, @@ -443,11 +443,11 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { config: { defaultModel: 'managed-fable', providers: { - 'managed:pythinker-code': { type: 'pythinker', apiKey: 'test-key' }, + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key' }, }, models: { 'managed-fable': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'claude-fable-5', maxContextSize: 200000, }, @@ -499,7 +499,7 @@ describe('resolveRuntimeProvider Pythinker request headers', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -550,7 +550,7 @@ describe('resolveRuntimeProvider Pythinker request headers', () => { config: { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: 'test-key', baseUrl: 'https://api.example/v1', @@ -856,11 +856,11 @@ describe('ProviderManager OAuth auth', () => { return { ...BASE_CONFIG, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }; @@ -870,8 +870,8 @@ describe('ProviderManager OAuth auth', () => { 'rejects an API key environment reference with OAuth when its value is %#', (value) => { const config = oauthConfig(); - config.providers['managed:pythinker-code'] = { - ...config.providers['managed:pythinker-code']!, + config.providers['managed:kimi-code'] = { + ...config.providers['managed:kimi-code']!, apiKeyEnvVar: 'OAUTH_CONFLICT_KEY', }; const manager = new ProviderManager({ diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 97ce22cf..0320fa01 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -686,7 +686,7 @@ custom_headers = { "X-Test" = "1" } const created = await rpc.createSession({ id: 'ses_runtime_service_oauth', workDir }); const session = core.sessions.get(created.id); - expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:pythinker-code', { + expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:kimi-code', { storage: 'file', key: 'oauth/custom-pythinker-code', }); diff --git a/packages/agent-core/test/loop/retry.test.ts b/packages/agent-core/test/loop/retry.test.ts index d1f07423..27839d5e 100644 --- a/packages/agent-core/test/loop/retry.test.ts +++ b/packages/agent-core/test/loop/retry.test.ts @@ -89,7 +89,7 @@ describe('chatWithRetry: terminated stream drops', () => { tokenCalls += 1; throw new PythinkerError( ErrorCodes.PROVIDER_CONNECTION_ERROR, - 'OAuth provider "managed:pythinker-code" failed to fetch an access token: fetch failed', + 'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed', ); }, }), @@ -147,16 +147,16 @@ function oauthConfig(): PythinkerConfig { return { defaultModel: 'pythinker-code/pythinker-for-coding', providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 1_000_000, }, diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index f6f78234..6589f20f 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -123,11 +123,11 @@ describe('PythinkerCore plugin RPCs', () => { await writeFile( path.join(home, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = "https://auth.dev.example.test" } +oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } `, 'utf8', ); diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index bff7414b..e5ec0e09 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -245,7 +245,7 @@ describe('CoreProcessService direct construction', () => { it('default-wires a resolveOAuthTokenProvider when caller omits one', () => { const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml')); expect(typeof resolver).toBe('function'); - const tokenProvider = resolver('managed:pythinker-code'); + const tokenProvider = resolver('managed:kimi-code'); expect(tokenProvider).toBeDefined(); expect(typeof tokenProvider?.getAccessToken).toBe('function'); }); diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index f6ab6c56..9fc1de54 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -7,7 +7,7 @@ import type { PythinkerConfigPatch, SetPythinkerConfigPayload, } from '../../src'; -import { PYTHINKER_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; +import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { type ICoreProcessService, @@ -248,18 +248,18 @@ describe('ModelCatalogService', () => { const configRef: { current: PythinkerConfig } = { current: { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', baseUrl: 'https://api.example.test/coding/v1', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: false, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 131_072, capabilities: ['thinking'], @@ -284,16 +284,16 @@ describe('ModelCatalogService', () => { const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade()); await expect(svc.refreshOAuthProviderModels()).resolves.toMatchObject({ - changed: [{ provider_id: PYTHINKER_CODE_PROVIDER_NAME, added: 0, removed: 0 }], + changed: [{ provider_id: KIMI_CODE_PROVIDER_NAME, added: 0, removed: 0 }], failed: [], }); - expect(removeCalls).toEqual([PYTHINKER_CODE_PROVIDER_NAME]); + expect(removeCalls).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(setCalls.at(-1)).toMatchObject({ - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, models: { - 'pythinker-code/pythinker-for-coding': { + 'kimi-code/pythinker-for-coding': { capabilities: ['thinking', 'always_thinking', 'tool_use'], maxContextSize: 262_144, }, diff --git a/packages/agent-core/test/services/oauth-service.test.ts b/packages/agent-core/test/services/oauth-service.test.ts index a69c0328..a6d5ce1a 100644 --- a/packages/agent-core/test/services/oauth-service.test.ts +++ b/packages/agent-core/test/services/oauth-service.test.ts @@ -79,7 +79,7 @@ function makeMockFacade(): MockFacade { }), logout: vi.fn(async (providerName: string | undefined) => { logoutCalls.push({ providerName }); - return { providerName: providerName ?? 'managed:pythinker-code', ok: true as const }; + return { providerName: providerName ?? 'managed:kimi-code', ok: true as const }; }), } as unknown as ServicesAuthFacade; @@ -136,7 +136,7 @@ describe('OAuthService.startLogin', () => { expect(start.user_code).toBe(auth.userCode); expect(start.expires_in).toBe(900); expect(start.interval).toBe(5); - expect(start.provider).toBe('managed:pythinker-code'); + expect(start.provider).toBe('managed:kimi-code'); }); it('falls back to 15-min expires_in when the OAuth host omits the field', async () => { @@ -191,7 +191,7 @@ describe('OAuthService — terminal transitions', () => { await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); await startPromise; - mock.loginCalls[0]!.resolve({ providerName: 'managed:pythinker-code', ok: true }); + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); await flushMicrotasks(); expect(impl.getFlow()!.status).toBe('authenticated'); @@ -278,7 +278,7 @@ describe('OAuthService.cancelLogin', () => { await flushMicrotasks(); await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); await startPromise; - mock.loginCalls[0]!.resolve({ providerName: 'managed:pythinker-code', ok: true }); + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); await flushMicrotasks(); const result = await impl.cancelLogin(); @@ -319,7 +319,7 @@ describe('OAuthService.logout', () => { it('delegates to facade.logout and returns logged_out=true', async () => { const { impl, mock } = makeImpl(); const result = await impl.logout(); - expect(result).toEqual({ logged_out: true, provider: 'managed:pythinker-code' }); + expect(result).toEqual({ logged_out: true, provider: 'managed:kimi-code' }); expect(mock.logoutCalls).toHaveLength(1); }); diff --git a/packages/migration-legacy/test/steps/config.test.ts b/packages/migration-legacy/test/steps/config.test.ts index 115638fb..9c24507b 100644 --- a/packages/migration-legacy/test/steps/config.test.ts +++ b/packages/migration-legacy/test/steps/config.test.ts @@ -34,17 +34,17 @@ base_url = "https://internal.example.com/v1" api_key = "EMPTY" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" -[providers."managed:pythinker-code".oauth] +[providers."managed:kimi-code".oauth] storage = "file" -key = "oauth/pythinker-code" +key = "oauth/kimi-code" `; describe('migrateConfigStep', () => { @@ -84,20 +84,20 @@ describe('migrateConfigStep', () => { it('reports a provider conflict and keeps the target provider', async () => { await writeFile( join(src, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://source.example/v1" `, ); await writeFile( join(tgt, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://target.example/v1" `, ); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); - expect(r.configConflicts).toContain('providers.managed:pythinker-code'); + expect(r.configConflicts).toContain('providers.managed:kimi-code'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).toContain('https://target.example/v1'); expect(cfg).not.toContain('https://source.example/v1'); @@ -154,20 +154,20 @@ base_url = "https://target.example/v1" }); it('drops a kept-provider model missing required schema fields', async () => { - // `bad-model` references the kept `managed:pythinker-code` provider but omits + // `bad-model` references the kept `managed:kimi-code` provider but omits // `max_context_size`, which pythinker-code's ModelAliasSchema requires. Written // verbatim it would make getConfig() reject the whole config post-migration. - const cfg = `[providers."managed:pythinker-code"] + const cfg = `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.pythinker.com/coding/v1" [models."good-model"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 [models."bad-model"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" `; await writeFile(join(src, 'config.toml'), cfg); @@ -207,13 +207,13 @@ model = "pythinker-for-coding" }); it('drops a model whose provider has no entry anywhere', async () => { - const cfg = `[providers."managed:pythinker-code"] + const cfg = `[providers."managed:kimi-code"] type = "pythinker" api_key = "k" base_url = "https://api.example/v1" [models."good"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 @@ -254,7 +254,7 @@ max_context_size = 1000 await writeFile( join(tgt, 'config.toml'), `[models."target-only"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 `, @@ -270,12 +270,12 @@ max_context_size = 1000 it('drops a migrated model whose provider conflicts with a differing target provider', async () => { await writeFile( join(src, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://legacy.example/v1" [models."conflicted"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "m" max_context_size = 1000 `, @@ -285,13 +285,13 @@ max_context_size = 1000 // to the wrong backend. await writeFile( join(tgt, 'config.toml'), - `[providers."managed:pythinker-code"] + `[providers."managed:kimi-code"] type = "pythinker" base_url = "https://target.example/v1" `, ); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); - expect(r.configConflicts).toContain('providers.managed:pythinker-code'); + expect(r.configConflicts).toContain('providers.managed:kimi-code'); expect(r.droppedModels).toContain('conflicted'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).not.toContain('conflicted'); diff --git a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts b/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts index d8cebeec..fe280ed6 100644 --- a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts +++ b/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts @@ -6,7 +6,7 @@ import { createPythinkerHarness, type PythinkerHarness } from '@pythoughts/pythi import { smokeIdentityFromEnv, runPromptToEnd } from './runtime-smoke-helpers'; -const MANAGED_PYTHINKER_CODE_PROVIDER = 'managed:pythinker-code'; +const MANAGED_KIMI_CODE_PROVIDER = 'managed:kimi-code'; async function main(): Promise { const explicitHomeDir = process.env['PYTHINKER_SDK_AUTH_SMOKE_HOME']; @@ -24,14 +24,14 @@ async function main(): Promise { try { if (forceLogin) { - await harness.auth.logout(MANAGED_PYTHINKER_CODE_PROVIDER); + await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER); process.stdout.write('cleared existing smoke token\n'); } const login = await harness.auth.login(undefined, { onDeviceCode: printDeviceCode }); const config = await harness.getConfig({ reload: true }); - const status = await harness.auth.status(MANAGED_PYTHINKER_CODE_PROVIDER); - const usage = await harness.auth.getManagedUsage(MANAGED_PYTHINKER_CODE_PROVIDER); + const status = await harness.auth.status(MANAGED_KIMI_CODE_PROVIDER); + const usage = await harness.auth.getManagedUsage(MANAGED_KIMI_CODE_PROVIDER); if (login.defaultModel === undefined || config.defaultModel === undefined) { throw new Error('login did not provision a default model'); @@ -39,7 +39,7 @@ async function main(): Promise { if (status.providers[0]?.hasToken !== true) { throw new Error('status did not report a stored token after login'); } - if (config.providers[MANAGED_PYTHINKER_CODE_PROVIDER]?.oauth?.key !== 'oauth/pythinker-code') { + if (config.providers[MANAGED_KIMI_CODE_PROVIDER]?.oauth?.key !== 'oauth/kimi-code') { throw new Error('managed provider oauth config was not written'); } @@ -59,7 +59,7 @@ async function main(): Promise { process.stdout.write(`auth smoke passed: ${session.id}\n`); } finally { if (!keepToken) { - await harness.auth.logout(MANAGED_PYTHINKER_CODE_PROVIDER).catch(() => {}); + await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER).catch(() => {}); } await harness.close(); if (explicitHomeDir === undefined && !keepToken) { diff --git a/packages/node-sdk/examples/pythinker-harness-config-smoke.ts b/packages/node-sdk/examples/pythinker-harness-config-smoke.ts index 6a75346e..53ca7939 100644 --- a/packages/node-sdk/examples/pythinker-harness-config-smoke.ts +++ b/packages/node-sdk/examples/pythinker-harness-config-smoke.ts @@ -21,16 +21,16 @@ async function main(): Promise { defaultPermissionMode: 'manual', defaultPlanMode: false, providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['image_in', 'thinking', 'video_in'], @@ -47,12 +47,12 @@ async function main(): Promise { pythoughtsSearch: { baseUrl: 'https://api.pythinker.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, pythoughtsFetch: { baseUrl: 'https://api.pythinker.com/coding/v1/fetch', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }); @@ -62,8 +62,8 @@ async function main(): Promise { for (const expected of [ 'default_model = "pythinker-code/pythinker-for-coding"', 'default_permission_mode = "manual"', - '[providers."managed:pythinker-code"]', - '[providers."managed:pythinker-code".oauth]', + '[providers."managed:kimi-code"]', + '[providers."managed:kimi-code".oauth]', '[models."pythinker-code/pythinker-for-coding"]', '[services.pythoughts_search]', ]) { @@ -76,7 +76,7 @@ async function main(): Promise { if (reloaded.defaultModel !== 'pythinker-code/pythinker-for-coding') { throw new Error('reloaded config did not preserve defaultModel'); } - if (reloaded.providers['managed:pythinker-code']?.oauth?.key !== 'oauth/pythinker-code') { + if (reloaded.providers['managed:kimi-code']?.oauth?.key !== 'oauth/kimi-code') { throw new Error('reloaded config did not preserve provider oauth'); } diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 4613ebc0..baebb229 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -7,19 +7,19 @@ import { type OAuthRef, } from '@pythoughts/agent-core'; import { - applyManagedPythinkerCodeConfig, - applyManagedPythinkerCodeLogoutConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + applyManagedKimiCodeLogoutConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeRuntimeAuth, + resolveKimiCodeLoginAuth, + resolveKimiCodeRuntimeAuth, type AuthManagedUsageResult, type AuthStatus, type BearerTokenProvider, type FetchSubmitFeedbackResult, type PythinkerHostIdentity, type PythinkerOAuthLoginOptions, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, type OAuthRefreshOutcome, } from '@pythoughts/pythinker-code-oauth'; @@ -56,7 +56,7 @@ export interface PythinkerAuthFacadeOptions { readonly onRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; } -type SDKManagedConfig = PythinkerConfig & ManagedPythinkerConfigShape; +type SDKManagedConfig = PythinkerConfig & ManagedKimiConfigShape; export class PythinkerAuthFacade { private readonly toolkit: PythinkerOAuthToolkit; @@ -74,8 +74,8 @@ export class PythinkerAuthFacade { write: async (config) => { await writeConfigFile(options.configPath, config); }, - apply: applyManagedPythinkerCodeConfig, - remove: applyManagedPythinkerCodeLogoutConfig, + apply: applyManagedKimiCodeConfig, + remove: applyManagedKimiCodeLogoutConfig, }, }); } @@ -85,11 +85,11 @@ export class PythinkerAuthFacade { } async login( - providerName: string | undefined = PYTHINKER_CODE_PROVIDER_NAME, + providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, options: PythinkerAuthLoginOptions = {}, ): Promise { const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolvePythinkerCodeLoginAuth({ + const loginAuth = resolveKimiCodeLoginAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, requestedBaseUrl: options.baseUrl, @@ -193,7 +193,7 @@ export class PythinkerAuthFacade { readonly oauthRef?: OAuthRef | undefined; readonly baseUrl?: string | undefined; } { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; // Read path: token/status resolution must work off a degraded config // instead of failing the session when an unrelated section is broken. // Write paths (the toolkit's configAdapter.read) stay strict. @@ -210,7 +210,7 @@ export class PythinkerAuthFacade { readonly baseUrl?: string | undefined; } { const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: auth.oauthRef, }); @@ -220,9 +220,9 @@ export class PythinkerAuthFacade { providerName: string | undefined, oauthRef?: OAuthRef | undefined, ): OAuthRef | undefined { - if ((providerName ?? PYTHINKER_CODE_PROVIDER_NAME) !== PYTHINKER_CODE_PROVIDER_NAME) return oauthRef; + if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) return oauthRef; const auth = this.resolveManagedAuth(providerName); - return resolvePythinkerCodeRuntimeAuth({ + return resolveKimiCodeRuntimeAuth({ configuredBaseUrl: auth.baseUrl, configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; diff --git a/packages/node-sdk/src/pythinker-code-model-provider.ts b/packages/node-sdk/src/pythinker-code-model-provider.ts index 781387da..91bf40e4 100644 --- a/packages/node-sdk/src/pythinker-code-model-provider.ts +++ b/packages/node-sdk/src/pythinker-code-model-provider.ts @@ -8,13 +8,13 @@ import { } from '@pythoughts/agent-core'; import { createPythinkerDefaultHeaders, - PYTHINKER_CODE_FLOW_CONFIG, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_FLOW_CONFIG, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - pythinkerCodeBaseUrl, - resolvePythinkerCodeOAuthRef, + kimiCodeBaseUrl, + resolveKimiCodeOAuthRef, type PythinkerHostIdentity, - type ManagedPythinkerOAuthRef, + type ManagedKimiOAuthRef, } from '@pythoughts/pythinker-code-oauth'; import type { ProviderConfig as KosongProviderConfig, @@ -40,11 +40,11 @@ export class PythinkerForCodingProvider implements ModelProvider { private readonly toolkit: PythinkerOAuthToolkit; private readonly homeDir: string; private readonly identity: PythinkerHostIdentity; - private readonly oauthRef: ManagedPythinkerOAuthRef; + private readonly oauthRef: ManagedKimiOAuthRef; constructor(options: PythinkerForCodingProviderOptions) { this.model = options.model ?? 'pythinker-for-coding'; - this.baseUrl = options.baseUrl ?? pythinkerCodeBaseUrl(); + this.baseUrl = options.baseUrl ?? kimiCodeBaseUrl(); this.promptCacheKey = options.promptCacheKey; this.defaultHeaders = options.defaultHeaders; this.homeDir = resolvePythinkerHome(options.homeDir); @@ -53,8 +53,8 @@ export class PythinkerForCodingProvider implements ModelProvider { version: options.version, userAgentSuffix: options.userAgentSuffix, }; - this.oauthRef = resolvePythinkerCodeOAuthRef({ - oauthHost: PYTHINKER_CODE_FLOW_CONFIG.oauthHost, + this.oauthRef = resolveKimiCodeOAuthRef({ + oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost, baseUrl: this.baseUrl, }); this.toolkit = new PythinkerOAuthToolkit({ @@ -122,7 +122,7 @@ export class PythinkerForCodingProvider implements ModelProvider { private async buildAuth(force: boolean): Promise { try { - const apiKey = await this.toolkit.ensureFresh(PYTHINKER_CODE_PROVIDER_NAME, { + const apiKey = await this.toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { force, oauthRef: this.oauthRef, }); @@ -132,7 +132,7 @@ export class PythinkerForCodingProvider implements ModelProvider { // turn surfaces `auth.login_required` / `provider.connection_error` // instead of collapsing everything to `internal`. Unrecognized errors are // rethrown raw (see mapOAuthTokenError). - throw mapOAuthTokenError(error, PYTHINKER_CODE_PROVIDER_NAME) ?? error; + throw mapOAuthTokenError(error, KIMI_CODE_PROVIDER_NAME) ?? error; } } } diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 202d2a3a..5a371363 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -4,12 +4,12 @@ import { join } from 'node:path'; import { FileTokenStorage, - PYTHINKER_CODE_PROVIDER_NAME, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, OAuthConnectionError, OAuthError, RetryableRefreshError, - resolvePythinkerCodeOAuthKey, + resolveKimiCodeOAuthKey, resolvePythinkerTokenStorageName, type TokenInfo, } from '@pythoughts/pythinker-code-oauth'; @@ -60,7 +60,7 @@ describe('PythinkerHarness.auth', () => { }); it('exposes a cached access token without refreshing auth state', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); @@ -70,7 +70,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).rejects.toMatchObject({ code: ErrorCodes.AUTH_LOGIN_REQUIRED, }); @@ -94,7 +94,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); const error = await harness.auth - .resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME) + .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME) .getAccessToken() .catch((error: unknown) => error); @@ -123,7 +123,7 @@ describe('PythinkerHarness.auth', () => { const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).rejects.toBe(oauthError); } finally { tokenProviderSpy.mockRestore(); @@ -131,11 +131,11 @@ describe('PythinkerHarness.auth', () => { }); it('resolves managed auth from a partially invalid config without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" @@ -149,23 +149,23 @@ max_steps_per_turn = "abc" // config.toml must degrade, not break OAuth-backed sessions. await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); await expect(harness.auth.status()).resolves.toMatchObject({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); }); it('resolves cached access tokens from the configured scoped OAuth ref', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" @@ -178,7 +178,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. }); it('reports auth status from the configured scoped OAuth ref', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); @@ -189,7 +189,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" @@ -199,12 +199,12 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); }); it('provisions SDK config using an existing Pythinker OAuth token', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn( async (_input, _init) => new Response( @@ -230,9 +230,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const config = await harness.getConfig({ reload: true }); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, }); expect(fetchMock).toHaveBeenCalledWith( @@ -243,8 +243,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. }), }), ); - expect(config.defaultModel).toBe('pythinker-code/pythinker-for-coding'); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ + expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); + expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], displayName: 'Pythinker for Coding', }); @@ -253,21 +253,21 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. tool_use: true, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); expect(config.services?.pythoughtsSearch?.oauth).toEqual({ storage: 'file', - key: 'oauth/pythinker-code', + key: 'oauth/kimi-code', }); }); it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost, baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); await storage.save(storageName, { @@ -279,7 +279,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" @@ -325,9 +325,9 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(refreshBody?.get('grant_type')).toBe('refresh_token'); expect(refreshBody?.get('refresh_token')).toBe('dev-refresh-token'); @@ -336,7 +336,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } accessToken: 'rotated-dev-access-token', }); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', key: oauthKey, oauthHost }, }); @@ -348,10 +348,10 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } it('recomputes legacy managed OAuth refs during login for non-default base URLs', async () => { const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', { ...freshToken(), accessToken: 'legacy-access-token' }); + await storage.save('kimi-code', { ...freshToken(), accessToken: 'legacy-access-token' }); await storage.save(scopedStorageName, { ...freshToken(), accessToken: 'scoped-access-token', @@ -359,11 +359,11 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } `, ); const fetchMock = vi.fn(async (input, init) => { @@ -386,14 +386,14 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(fetchMock).toHaveBeenCalledTimes(1); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', key: oauthKey, oauthHost: 'https://auth.kimi.com' }, }); @@ -403,8 +403,8 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthKey = resolvePythinkerCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl }); + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); await storage.save(resolvePythinkerTokenStorageName({ oauthKey: configuredOauthKey }), { ...freshToken(), @@ -417,7 +417,7 @@ oauth = { storage = "file", key = "oauth/pythinker-code" } await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${configuredBaseUrl}" api_key = "" @@ -446,32 +446,32 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', }); expect(fetchMock).toHaveBeenCalledTimes(1); const config = await harness.getConfig({ reload: true }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl: envBaseUrl, oauth: { storage: 'file', key: envOauthKey, oauthHost: envOauthHost }, }); }); it('starts degraded when a configured model alias does not have max_context_size', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" `, ); @@ -500,30 +500,30 @@ model = "pythinker-for-coding" // dropped, the rest of the config survives, and a warning is reported. const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); const config = await harness.getConfig(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeDefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined(); const { warnings } = await harness.getConfigDiagnostics(); expect(warnings.some((w) => w.includes('models.pythinker-code/pythinker-for-coding'))).toBe(true); }); it('removes managed Pythinker config on logout', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [providers.custom] type = "pythinker" api_key = "sk-existing" [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 @@ -535,62 +535,62 @@ max_context_size = 1000 [services.pythoughts_search] base_url = "https://api.pythinker.com/coding/v1/search" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [services.pythoughts_fetch] base_url = "https://api.pythinker.com/coding/v1/fetch" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } `, ); const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); const config = await harness.getConfig({ reload: true }); expect(config.defaultModel).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); await expect( - new FileTokenStorage(join(homeDir, 'credentials')).load('pythinker-code'), + new FileTokenStorage(join(homeDir, 'credentials')).load('kimi-code'), ).resolves.toBeUndefined(); const text = await readFile(join(homeDir, 'config.toml'), 'utf-8'); - expect(text).not.toContain('managed:pythinker-code'); - expect(text).not.toContain('pythinker-code/pythinker-for-coding'); + expect(text).not.toContain('managed:kimi-code'); + expect(text).not.toContain('kimi-code/pythinker-for-coding'); expect(text).not.toContain('pythoughts_search'); }); it('removes the configured scoped OAuth token on logout without touching the production token', async () => { - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); await writeFile( join(homeDir, 'config.toml'), ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "https://api.dev.example.test/coding/v1" api_key = "" oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 `, @@ -598,22 +598,22 @@ max_context_size = 262144 const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); await expect(storage.load(storageName)).resolves.toBeUndefined(); - await expect(storage.load('pythinker-code')).resolves.toMatchObject({ + await expect(storage.load('kimi-code')).resolves.toMatchObject({ accessToken: 'oauth-access-token', }); }); it('recomputes legacy managed OAuth refs during logout for non-default base URLs', async () => { const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('pythinker-code', freshToken()); + await storage.save('kimi-code', freshToken()); await storage.save(scopedStorageName, { ...freshToken(), accessToken: 'scoped-access-token', @@ -623,14 +623,14 @@ max_context_size = 262144 ` default_model = "pythinker-code/pythinker-for-coding" -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" -oauth = { storage = "file", key = "oauth/pythinker-code" } +oauth = { storage = "file", key = "oauth/kimi-code" } [models."pythinker-code/pythinker-for-coding"] -provider = "managed:pythinker-code" +provider = "managed:kimi-code" model = "pythinker-for-coding" max_context_size = 262144 `, @@ -638,18 +638,18 @@ max_context_size = 262144 const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); await expect(storage.load(scopedStorageName)).resolves.toBeUndefined(); - await expect(storage.load('pythinker-code')).resolves.toMatchObject({ + await expect(storage.load('kimi-code')).resolves.toMatchObject({ accessToken: 'oauth-access-token', }); }); it('gets managed usage without host identity and sends only auth headers', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn( async (_input, _init) => new Response( @@ -678,7 +678,7 @@ max_context_size = 262144 it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl, }); @@ -690,7 +690,7 @@ max_context_size = 262144 await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${baseUrl}" api_key = "" @@ -720,7 +720,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. sessionId: 'sess-dev', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }), ).resolves.toEqual({ kind: 'ok' }); @@ -736,8 +736,8 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1'; const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthKey = resolvePythinkerCodeOAuthKey({ + const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl, }); @@ -753,7 +753,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. await writeFile( join(homeDir, 'config.toml'), ` -[providers."managed:pythinker-code"] +[providers."managed:kimi-code"] type = "pythinker" base_url = "${configuredBaseUrl}" api_key = "" @@ -776,15 +776,15 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ const harness = createPythinkerHarness({ homeDir }); await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); await expect(harness.auth.getCachedAccessToken()).resolves.toBe('env-access-token'); await expect( - harness.auth.resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(), + harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), ).resolves.toBe('env-access-token'); await expect( harness.auth - .resolveOAuthTokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME, { storage: 'file', key: configuredOauthKey, oauthHost: 'https://auth.pythinker.com', @@ -801,7 +801,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ sessionId: 'sess-env', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }), ).resolves.toEqual({ kind: 'ok' }); @@ -813,7 +813,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ }); it('submitFeedback maps camelCase input to snake_case body and posts with bearer auth', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); const fetchMock = vi.fn(async () => new Response('', { status: 200 })); vi.stubGlobal('fetch', fetchMock); @@ -823,7 +823,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ sessionId: 'sess-42', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }); expect(result).toEqual({ kind: 'ok' }); @@ -842,12 +842,12 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ content: 'great tool', version: 'pythinker-code-0.1.1', os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', + model: 'kimi-code/pythinker-for-coding', }); }); it('submitFeedback surfaces HTTP errors without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); vi.stubGlobal( 'fetch', vi.fn( diff --git a/packages/node-sdk/test/runtime-provider-oauth.test.ts b/packages/node-sdk/test/runtime-provider-oauth.test.ts index 47b28ce7..6ae2af1a 100644 --- a/packages/node-sdk/test/runtime-provider-oauth.test.ts +++ b/packages/node-sdk/test/runtime-provider-oauth.test.ts @@ -7,16 +7,16 @@ import { ProviderManager } from '../../agent-core/src/session/provider-manager'; function managedConfig(): PythinkerConfig { return { providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -118,12 +118,12 @@ describe('resolveRuntimeProviderWithOAuth', () => { const resolved = await resolveRuntimeProviderWithOAuth({ config: managedConfig(), resolveOAuthTokenProvider: (_providerName, oauthRef) => { - expect(oauthRef).toEqual({ storage: 'file', key: 'oauth/pythinker-code' }); + expect(oauthRef).toEqual({ storage: 'file', key: 'oauth/kimi-code' }); return { getAccessToken }; }, }); - expect(resolved.providerName).toBe('managed:pythinker-code'); + expect(resolved.providerName).toBe('managed:kimi-code'); expect(resolved.provider).toMatchObject({ type: 'pythinker', model: 'pythinker-for-coding', @@ -149,11 +149,11 @@ describe('resolveRuntimeProviderWithOAuth', () => { const conflicting: PythinkerConfig = { ...managedConfig(), providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: 'static-key', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, }; @@ -196,7 +196,7 @@ describe('resolveRuntimeProviderWithOAuth', () => { expect(log.warn).toHaveBeenCalledWith( 'oauth token fetch failed', expect.objectContaining({ - providerName: 'managed:pythinker-code', + providerName: 'managed:kimi-code', error: expect.any(Error), }), ); diff --git a/packages/node-sdk/test/session-set-model.test.ts b/packages/node-sdk/test/session-set-model.test.ts index 3b6c7d42..631bc611 100644 --- a/packages/node-sdk/test/session-set-model.test.ts +++ b/packages/node-sdk/test/session-set-model.test.ts @@ -66,21 +66,21 @@ describe('Session.setModel', () => { try { await harness.setConfig({ providers: { - 'managed:pythinker-code': { + 'managed:kimi-code': { type: 'pythinker', baseUrl: 'https://api.pythinker.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, models: { 'pythinker-code/initial': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-initial', maxContextSize: 262144, }, 'pythinker-code/pythinker-for-coding': { - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', model: 'pythinker-for-coding', maxContextSize: 262144, }, diff --git a/packages/oauth/examples/pythinker-oauth-smoke.ts b/packages/oauth/examples/kimi-oauth-smoke.ts similarity index 76% rename from packages/oauth/examples/pythinker-oauth-smoke.ts rename to packages/oauth/examples/kimi-oauth-smoke.ts index c1c3fd80..4b23e016 100644 --- a/packages/oauth/examples/pythinker-oauth-smoke.ts +++ b/packages/oauth/examples/kimi-oauth-smoke.ts @@ -3,28 +3,28 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - applyManagedPythinkerCodeConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, type DeviceAuthorization, type PythinkerHostIdentity, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '@pythoughts/pythinker-code-oauth'; async function main(): Promise { const explicitHomeDir = process.env['PYTHINKER_OAUTH_SMOKE_HOME']; - const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'pythinker-oauth-smoke-'))); + const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'kimi-oauth-smoke-'))); const keepToken = shouldKeepToken(explicitHomeDir !== undefined); const forceLogin = process.env['PYTHINKER_OAUTH_SMOKE_FORCE_LOGIN'] === '1'; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - const toolkit = new PythinkerOAuthToolkit({ + const toolkit = new PythinkerOAuthToolkit({ homeDir, identity: smokeIdentityFromEnv(), configAdapter: { read: () => config, write: () => {}, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, configPath: '', }, }); @@ -33,16 +33,16 @@ async function main(): Promise { try { if (forceLogin) { - await toolkit.logout(PYTHINKER_CODE_PROVIDER_NAME); + await toolkit.logout(KIMI_CODE_PROVIDER_NAME); process.stdout.write('cleared existing smoke token\n'); } - const login = await toolkit.login(PYTHINKER_CODE_PROVIDER_NAME, { + const login = await toolkit.login(KIMI_CODE_PROVIDER_NAME, { onDeviceCode: printDeviceCode, }); - const status = await toolkit.status(PYTHINKER_CODE_PROVIDER_NAME); - const accessToken = await toolkit.tokenProvider(PYTHINKER_CODE_PROVIDER_NAME).getAccessToken(); - const usage = await toolkit.getManagedUsage(PYTHINKER_CODE_PROVIDER_NAME); + const status = await toolkit.status(KIMI_CODE_PROVIDER_NAME); + const accessToken = await toolkit.tokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(); + const usage = await toolkit.getManagedUsage(KIMI_CODE_PROVIDER_NAME); if (login.provision?.defaultModel === undefined) { throw new Error('login did not provision a default model'); @@ -53,7 +53,7 @@ async function main(): Promise { if (accessToken.length === 0) { throw new Error('token provider returned an empty access token'); } - if (config.providers[PYTHINKER_CODE_PROVIDER_NAME] === undefined) { + if (config.providers[KIMI_CODE_PROVIDER_NAME] === undefined) { throw new Error('managed provider was not written to config'); } @@ -64,7 +64,7 @@ async function main(): Promise { process.stdout.write('oauth smoke passed\n'); } finally { if (!keepToken) { - await toolkit.logout(PYTHINKER_CODE_PROVIDER_NAME).catch(() => {}); + await toolkit.logout(KIMI_CODE_PROVIDER_NAME).catch(() => {}); } if (explicitHomeDir === undefined && !keepToken) { await rm(homeDir, { recursive: true, force: true }); @@ -75,7 +75,7 @@ async function main(): Promise { function smokeIdentityFromEnv(): PythinkerHostIdentity { const version = process.env['PYTHINKER_CODE_SMOKE_VERSION']; if (version === undefined || version.trim().length === 0) { - throw new Error('PYTHINKER_CODE_SMOKE_VERSION is required for Pythinker OAuth smoke.'); + throw new Error('PYTHINKER_CODE_SMOKE_VERSION is required for Kimi OAuth smoke.'); } return { userAgentProduct: "pythinker-code-cli", @@ -86,7 +86,7 @@ function smokeIdentityFromEnv(): PythinkerHostIdentity { function printDeviceCode(auth: DeviceAuthorization): void { process.stdout.write( [ - 'Complete Pythinker OAuth device login:', + 'Complete Kimi OAuth device login:', ` URL: ${auth.verificationUriComplete || auth.verificationUri}`, ` Code: ${auth.userCode}`, auth.expiresIn === null ? undefined : ` Expires in: ${String(auth.expiresIn)}s`, @@ -98,7 +98,7 @@ function printDeviceCode(auth: DeviceAuthorization): void { } function printUsage( - usage: Awaited['getManagedUsage']>>, + usage: Awaited['getManagedUsage']>>, ): void { if (usage.kind === 'error') { process.stderr.write(`usage request returned: ${usage.message}\n`); diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 4038be8c..5357edcf 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -2,7 +2,7 @@ "name": "@pythoughts/pythinker-code-oauth", "version": "0.2.2", "private": true, - "description": "Pythinker OAuth toolkit for managed Pythinker Code authentication", + "description": "OAuth toolkit for managed Kimi Code authentication", "license": "MIT", "author": "Pythoughts", "homepage": "https://github.com/Pythoughts-labs/pythinker-code/tree/main/packages/oauth#readme", diff --git a/packages/oauth/src/constants.ts b/packages/oauth/src/constants.ts index 87cf6bd7..7e86c471 100644 --- a/packages/oauth/src/constants.ts +++ b/packages/oauth/src/constants.ts @@ -1,12 +1,12 @@ import type { OAuthFlowConfig } from './types'; -export const DEFAULT_PYTHINKER_CODE_OAUTH_HOST = 'https://auth.kimi.com'; +export const DEFAULT_KIMI_CODE_OAUTH_HOST = 'https://auth.kimi.com'; -export const PYTHINKER_CODE_FLOW_CONFIG: OAuthFlowConfig = { - name: 'pythinker-code', +export const KIMI_CODE_FLOW_CONFIG: OAuthFlowConfig = { + name: 'kimi-code', oauthHost: process.env['PYTHINKER_CODE_OAUTH_HOST'] ?? process.env['PYTHINKER_OAUTH_HOST'] ?? - DEFAULT_PYTHINKER_CODE_OAUTH_HOST, + DEFAULT_KIMI_CODE_OAUTH_HOST, clientId: '17e5f671-d194-4dfb-9706-5516cb48c098', }; diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 29b4e142..6113e16a 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -1,8 +1,8 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import type { ManagedPythinkerConfigShape } from './managed-pythinker-code'; +import type { ManagedKimiConfigShape } from './managed-kimi-code'; -export type { ManagedPythinkerConfigShape }; +export type { ManagedKimiConfigShape }; /** * Identifies where a custom-registry-managed provider came from. The same @@ -288,11 +288,11 @@ function resolveCapabilities(model: CustomRegistryModelEntry): string[] { * Mirrors `applyOpenPlatformConfig`'s shape: provider goes to `config.providers` * keyed by `entry.id`, each model in `entry.models` becomes an alias under * `config.models[\`${entry.id}/${modelId}\`]`. The `source` blob is parked on the - * provider object via `ManagedPythinkerProviderConfig`'s index signature so the + * provider object via `ManagedKimiProviderConfig`'s index signature so the * refresh dispatcher can rediscover it later. */ export function applyCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, entry: CustomRegistryProviderEntry, source: CustomRegistrySource, ): void { @@ -339,7 +339,7 @@ export function applyCustomRegistryProvider( * `removeOpenPlatformConfig`. */ export function removeCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, providerId: string, ): void { delete config.providers[providerId]; @@ -383,7 +383,7 @@ export function removeCustomRegistryProvider( * registry". */ export function applyCustomRegistryEntries( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, entries: Record, source: CustomRegistrySource, ): void { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index eae88ffb..f75c7b2b 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -43,48 +43,48 @@ export { } from './identity'; export type { PythinkerHostIdentity, PythinkerIdentityOptions } from './identity'; -export { PYTHINKER_CODE_FLOW_CONFIG } from './constants'; +export { KIMI_CODE_FLOW_CONFIG } from './constants'; export { - applyManagedPythinkerCodeLogoutConfig, - applyManagedPythinkerCodeConfig, - clearManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - pythinkerCodeEnvBaseUrl, - pythinkerCodeEnvOAuthHost, - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PLATFORM_ID, - PYTHINKER_CODE_PROVIDER_NAME, - ManagedPythinkerCodeModelsAuthError, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, - resolvePythinkerCodeRuntimeAuth, -} from './managed-pythinker-code'; + applyManagedKimiCodeLogoutConfig, + applyManagedKimiCodeConfig, + clearManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + kimiCodeEnvBaseUrl, + kimiCodeEnvOAuthHost, + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PLATFORM_ID, + KIMI_CODE_PROVIDER_NAME, + ManagedKimiCodeModelsAuthError, + provisionManagedKimiCodeConfig, + resolveKimiCodeLoginAuth, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, + resolveKimiCodeRuntimeAuth, +} from './managed-kimi-code'; export type { - FetchManagedPythinkerCodeModelsOptions, - ManagedPythinkerCodeApplyResult, - ManagedPythinkerCodeCleanupResult, - ManagedPythinkerEnv, - ManagedPythinkerLoginAuth, - ManagedPythinkerCodeModelInfo, - ManagedPythinkerCodeProvisionResult, - ManagedPythinkerConfigAdapter, - ManagedPythinkerConfigShape, - ManagedPythinkerOAuthRef, - ManagedPythinkerOAuthRefInput, - ManagedPythinkerRuntimeAuth, - ProvisionManagedPythinkerCodeConfigOptions, -} from './managed-pythinker-code'; + FetchManagedKimiCodeModelsOptions, + ManagedKimiCodeApplyResult, + ManagedKimiCodeCleanupResult, + ManagedKimiEnv, + ManagedKimiLoginAuth, + ManagedKimiCodeModelInfo, + ManagedKimiCodeProvisionResult, + ManagedKimiConfigAdapter, + ManagedKimiConfigShape, + ManagedKimiOAuthRef, + ManagedKimiOAuthRefInput, + ManagedKimiRuntimeAuth, + ProvisionManagedKimiCodeConfigOptions, +} from './managed-kimi-code'; export { fetchManagedUsage, formatDuration, formatResetTime, - isManagedPythinkerCode, - pythinkerCodeBaseUrl, - pythinkerCodeUsageUrl, + isManagedKimiCode, + kimiCodeBaseUrl, + kimiCodeUsageUrl, parseManagedUsagePayload, } from './managed-usage'; export type { @@ -94,7 +94,7 @@ export type { UsageRow, } from './managed-usage'; -export { fetchSubmitFeedback, pythinkerCodeFeedbackUrl } from './managed-feedback'; +export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; export type { FetchSubmitFeedbackError, FetchSubmitFeedbackOk, diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts index d3ae1e85..faa65eeb 100644 --- a/packages/oauth/src/managed-feedback.ts +++ b/packages/oauth/src/managed-feedback.ts @@ -1,13 +1,13 @@ /** * Submit user feedback to the managed Pythinker Code platform. * - * POSTs a JSON body to `{pythinkerCodeBaseUrl}/feedback` with a Bearer access + * POSTs a JSON body to `{kimiCodeBaseUrl}/feedback` with a Bearer access * token. The client tags `version` with a `pythinker-code-` prefix so the * backend can identify this client. */ import { readApiErrorMessage } from './api-error'; -import { pythinkerCodeBaseUrl } from './managed-usage'; +import { kimiCodeBaseUrl } from './managed-usage'; export interface SubmitFeedbackBody { readonly session_id: string; @@ -29,8 +29,8 @@ export interface FetchSubmitFeedbackError { export type FetchSubmitFeedbackResult = FetchSubmitFeedbackOk | FetchSubmitFeedbackError; -export function pythinkerCodeFeedbackUrl(): string { - return `${pythinkerCodeBaseUrl().replace(/\/+$/, '')}/feedback`; +export function kimiCodeFeedbackUrl(): string { + return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/feedback`; } export async function fetchSubmitFeedback( diff --git a/packages/oauth/src/managed-pythinker-code.ts b/packages/oauth/src/managed-kimi-code.ts similarity index 69% rename from packages/oauth/src/managed-pythinker-code.ts rename to packages/oauth/src/managed-kimi-code.ts index 9254d59d..8050c88b 100644 --- a/packages/oauth/src/managed-pythinker-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -1,15 +1,15 @@ import { createHash } from 'node:crypto'; import { readApiErrorMessage } from './api-error'; -import { DEFAULT_PYTHINKER_CODE_OAUTH_HOST } from './constants'; +import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants'; import { OAuthUnauthorizedError } from './errors'; -import { DEFAULT_PYTHINKER_CODE_BASE_URL, pythinkerCodeBaseUrl } from './managed-usage'; +import { DEFAULT_KIMI_CODE_BASE_URL, kimiCodeBaseUrl } from './managed-usage'; import { isRecord } from './utils'; -export const PYTHINKER_CODE_PLATFORM_ID = 'pythinker-code'; -export const PYTHINKER_CODE_PROVIDER_NAME = 'managed:pythinker-code'; -export const PYTHINKER_CODE_OAUTH_KEY = 'oauth/pythinker-code'; -const PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/pythinker-code-env-'; +export const KIMI_CODE_PLATFORM_ID = 'kimi-code'; +export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; +export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code'; +const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-'; /** * Server-declared thinking toggle support from `/models`: @@ -21,11 +21,11 @@ const PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/pythinker-code-env-'; export type SupportsThinkingType = 'only' | 'no' | 'both'; /** - * Normalized model catalog entry returned by the managed Pythinker Code + * Normalized model catalog entry returned by the managed Kimi Code * `/models` endpoint; raw snake_case server fields map to these camelCase * fields. */ -export interface ManagedPythinkerCodeModelInfo { +export interface ManagedKimiCodeModelInfo { readonly id: string; readonly contextLength: number; readonly supportsReasoning: boolean; @@ -38,63 +38,63 @@ export interface ManagedPythinkerCodeModelInfo { readonly displayName?: string | undefined; } -export interface ManagedPythinkerCodeProvisionResult { - readonly providerName: typeof PYTHINKER_CODE_PROVIDER_NAME; +export interface ManagedKimiCodeProvisionResult { + readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; readonly defaultModel: string; readonly defaultThinking: boolean; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly configPath?: string | undefined; } -export interface FetchManagedPythinkerCodeModelsOptions { +export interface FetchManagedKimiCodeModelsOptions { readonly accessToken: string; readonly baseUrl?: string | undefined; readonly fetchImpl?: typeof fetch | undefined; } -export interface ManagedPythinkerCodeApplyResult { +export interface ManagedKimiCodeApplyResult { readonly defaultModel: string; readonly defaultThinking: boolean; } -export interface ManagedPythinkerCodeCleanupResult { - readonly providerName: typeof PYTHINKER_CODE_PROVIDER_NAME; +export interface ManagedKimiCodeCleanupResult { + readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; readonly removedProvider: boolean; readonly removedModels: readonly string[]; readonly defaultModelCleared: boolean; readonly removedServices: readonly string[]; } -export interface ManagedPythinkerOAuthRef { +export interface ManagedKimiOAuthRef { readonly storage: 'file' | 'keyring'; readonly key: string; readonly oauthHost?: string | undefined; } -export interface ManagedPythinkerOAuthRefInput { +export interface ManagedKimiOAuthRefInput { readonly storage?: 'file' | 'keyring' | undefined; readonly key?: string | undefined; readonly oauthHost?: string | undefined; } -export interface ManagedPythinkerRuntimeAuth { +export interface ManagedKimiRuntimeAuth { readonly baseUrl?: string | undefined; - readonly oauthRef: ManagedPythinkerOAuthRef; + readonly oauthRef: ManagedKimiOAuthRef; } -export interface ManagedPythinkerLoginAuth { +export interface ManagedKimiLoginAuth { readonly baseUrl?: string | undefined; readonly oauthHost?: string | undefined; - readonly oauthRef?: ManagedPythinkerOAuthRef | undefined; + readonly oauthRef?: ManagedKimiOAuthRef | undefined; } -export interface ManagedPythinkerEnv { +export interface ManagedKimiEnv { readonly PYTHINKER_CODE_BASE_URL?: string | undefined; readonly PYTHINKER_CODE_OAUTH_HOST?: string | undefined; readonly PYTHINKER_OAUTH_HOST?: string | undefined; } -export class ManagedPythinkerCodeModelsAuthError extends OAuthUnauthorizedError { +export class ManagedKimiCodeModelsAuthError extends OAuthUnauthorizedError { readonly status: number; readonly baseUrl: string; @@ -104,23 +104,23 @@ export class ManagedPythinkerCodeModelsAuthError extends OAuthUnauthorizedError readonly message: string; }) { super( - `Pythinker Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, + `Kimi Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, ); - this.name = 'ManagedPythinkerCodeModelsAuthError'; + this.name = 'ManagedKimiCodeModelsAuthError'; this.status = options.status; this.baseUrl = options.baseUrl; } } -export interface ManagedPythinkerProviderConfig { +export interface ManagedKimiProviderConfig { type: 'pythinker'; baseUrl?: string | undefined; apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; + oauth?: ManagedKimiOAuthRef | undefined; readonly [key: string]: unknown; } -export interface ManagedPythinkerModelAlias { +export interface ManagedKimiModelAlias { provider: string; model: string; maxContextSize: number; @@ -130,50 +130,50 @@ export interface ManagedPythinkerModelAlias { readonly [key: string]: unknown; } -export interface ManagedPythinkerServiceConfig { +export interface ManagedKimiServiceConfig { baseUrl?: string | undefined; apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; + oauth?: ManagedKimiOAuthRef | undefined; } -export interface ManagedPythinkerServicesConfig { - pythoughtsSearch?: ManagedPythinkerServiceConfig | undefined; - pythoughtsFetch?: ManagedPythinkerServiceConfig | undefined; +export interface ManagedKimiServicesConfig { + pythoughtsSearch?: ManagedKimiServiceConfig | undefined; + pythoughtsFetch?: ManagedKimiServiceConfig | undefined; readonly [key: string]: unknown; } -export interface ManagedPythinkerConfigShape { - providers: Record>; - models?: Record> | undefined; +export interface ManagedKimiConfigShape { + providers: Record>; + models?: Record> | undefined; defaultModel?: string | undefined; defaultThinking?: boolean | undefined; thinking?: { mode?: 'auto' | 'on' | 'off'; effort?: string; }; - services?: ManagedPythinkerServicesConfig | undefined; + services?: ManagedKimiServicesConfig | undefined; [key: string]: unknown; } -export interface ManagedPythinkerConfigAdapter { +export interface ManagedKimiConfigAdapter { read(): Promise | TConfig; write(config: TConfig): Promise | void; apply( config: TConfig, input: { - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; readonly oauthHost?: string | undefined; readonly preserveDefaultModel?: boolean | undefined; }, - ): ManagedPythinkerCodeApplyResult; + ): ManagedKimiCodeApplyResult; remove?(config: TConfig): void; readonly configPath?: string | undefined; } -export interface ProvisionManagedPythinkerCodeConfigOptions { - readonly adapter: ManagedPythinkerConfigAdapter; +export interface ProvisionManagedKimiCodeConfigOptions { + readonly adapter: ManagedKimiConfigAdapter; readonly accessToken: string; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; @@ -183,7 +183,7 @@ export interface ProvisionManagedPythinkerCodeConfigOptions { } function managedModelKey(modelId: string): string { - return `${PYTHINKER_CODE_PLATFORM_ID}/${modelId}`; + return `${KIMI_CODE_PLATFORM_ID}/${modelId}`; } interface SelectedDefaultModel { @@ -191,7 +191,7 @@ interface SelectedDefaultModel { readonly thinking: boolean; } -function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | undefined { +function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); // supports_thinking_type is the full three-state declaration and wins over // the legacy supports_reasoning boolean; absent (older servers) falls back. @@ -219,7 +219,7 @@ function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | } function defaultBaseUrl(baseUrl: string | undefined): string { - return (baseUrl ?? pythinkerCodeBaseUrl()).replace(/\/+$/, ''); + return (baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, ''); } function normalizeBaseUrl(baseUrl: string): string { @@ -235,10 +235,10 @@ function persistedOAuthHost(options: { readonly oauthHost?: string | undefined; }): string | undefined { const oauthHost = options.oauthHost; - const normalized = normalizeEndpoint(oauthHost ?? DEFAULT_PYTHINKER_CODE_OAUTH_HOST); + const normalized = normalizeEndpoint(oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); if ( - options.key === PYTHINKER_CODE_OAUTH_KEY && - normalized === normalizeEndpoint(DEFAULT_PYTHINKER_CODE_OAUTH_HOST) + options.key === KIMI_CODE_OAUTH_KEY && + normalized === normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST) ) { return undefined; } @@ -249,7 +249,7 @@ function managedOAuthRef(options: { readonly key: string; readonly oauthHost?: string | undefined; readonly storage?: 'file' | 'keyring' | undefined; -}): ManagedPythinkerOAuthRef { +}): ManagedKimiOAuthRef { const oauthHost = persistedOAuthHost(options); return { storage: options.storage ?? 'file', @@ -259,8 +259,8 @@ function managedOAuthRef(options: { } function configuredOAuthRef( - oauthRef: ManagedPythinkerOAuthRefInput | undefined, -): ManagedPythinkerOAuthRef | undefined { + oauthRef: ManagedKimiOAuthRefInput | undefined, +): ManagedKimiOAuthRef | undefined { if (oauthRef === undefined) return undefined; const key = oauthRef.key; if (key === undefined) return undefined; @@ -272,16 +272,16 @@ function configuredOAuthRef( } /** - * Managed Pythinker Code base URL from the environment. + * Managed Kimi Code base URL from the environment. */ -export function pythinkerCodeEnvBaseUrl(env: ManagedPythinkerEnv = process.env): string | undefined { +export function kimiCodeEnvBaseUrl(env: ManagedKimiEnv = process.env): string | undefined { return env.PYTHINKER_CODE_BASE_URL; } /** - * Managed Pythinker Code OAuth host from the environment. + * Managed Kimi Code OAuth host from the environment. */ -export function pythinkerCodeEnvOAuthHost(env: ManagedPythinkerEnv = process.env): string | undefined { +export function kimiCodeEnvOAuthHost(env: ManagedKimiEnv = process.env): string | undefined { return env.PYTHINKER_CODE_OAUTH_HOST ?? env.PYTHINKER_OAUTH_HOST; } @@ -289,28 +289,28 @@ export function pythinkerCodeEnvOAuthHost(env: ManagedPythinkerEnv = process.env * Returns the credential-storage key for an (oauthHost, baseUrl) pair: the * global slot for defaults, otherwise a hash-scoped per-environment slot. */ -export function resolvePythinkerCodeOAuthKey(options: { +export function resolveKimiCodeOAuthKey(options: { readonly oauthHost?: string | undefined; readonly baseUrl?: string | undefined; }): string { - const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_PYTHINKER_CODE_OAUTH_HOST); + const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); const baseUrl = defaultBaseUrl(options.baseUrl); - const defaultOauthHost = normalizeEndpoint(DEFAULT_PYTHINKER_CODE_OAUTH_HOST); - const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_PYTHINKER_CODE_BASE_URL); + const defaultOauthHost = normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST); + const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL); if (oauthHost === defaultOauthHost && baseUrl === defaultApiBaseUrl) { - return PYTHINKER_CODE_OAUTH_KEY; + return KIMI_CODE_OAUTH_KEY; } const digest = createHash('sha256') .update(JSON.stringify({ oauthHost, baseUrl })) .digest('hex') .slice(0, 16); - return `${PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; + return `${KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; } /** - * Resolve the full managed-Pythinker-Code OAuth ref (credential storage key + + * Resolve the full managed-Kimi-Code OAuth ref (credential storage key + * persisted host) for an (oauthHost, baseUrl) environment. * * Single source of truth for "which credential slot does this environment map @@ -319,12 +319,12 @@ export function resolvePythinkerCodeOAuthKey(options: { * is later read from — preventing the env-mismatch credential mix-ups this * scoping is meant to fix. */ -export function resolvePythinkerCodeOAuthRef(options: { +export function resolveKimiCodeOAuthRef(options: { readonly oauthHost?: string | undefined; readonly baseUrl?: string | undefined; -}): ManagedPythinkerOAuthRef { +}): ManagedKimiOAuthRef { return managedOAuthRef({ - key: resolvePythinkerCodeOAuthKey(options), + key: resolveKimiCodeOAuthKey(options), oauthHost: options.oauthHost, }); } @@ -334,18 +334,18 @@ export function resolvePythinkerCodeOAuthRef(options: { * the runtime auth the managed provider should use, migrating to the env's * credential slot when they disagree. */ -export function resolvePythinkerCodeRuntimeAuth(options: { +export function resolveKimiCodeRuntimeAuth(options: { readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedPythinkerOAuthRefInput | undefined; - readonly env?: ManagedPythinkerEnv | undefined; -}): ManagedPythinkerRuntimeAuth { + readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; + readonly env?: ManagedKimiEnv | undefined; +}): ManagedKimiRuntimeAuth { const env = options.env ?? process.env; - const envBaseUrl = pythinkerCodeEnvBaseUrl(env); - const envOAuthHost = pythinkerCodeEnvOAuthHost(env); + const envBaseUrl = kimiCodeEnvBaseUrl(env); + const envOAuthHost = kimiCodeEnvOAuthHost(env); const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; const baseUrl = envBaseUrl !== undefined ? normalizeBaseUrl(envBaseUrl) : options.configuredBaseUrl; - const expected = resolvePythinkerCodeOAuthRef({ + const expected = resolveKimiCodeOAuthRef({ oauthHost: hasEnvOverride ? envOAuthHost : options.configuredOAuthRef?.oauthHost, baseUrl, }); @@ -361,16 +361,16 @@ export function resolvePythinkerCodeRuntimeAuth(options: { * then env overrides, then the configured ref validated against the key the * resolved base URL implies. */ -export function resolvePythinkerCodeLoginAuth(options: { +export function resolveKimiCodeLoginAuth(options: { readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedPythinkerOAuthRefInput | undefined; + readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; readonly requestedBaseUrl?: string | undefined; readonly requestedOAuthHost?: string | undefined; - readonly env?: ManagedPythinkerEnv | undefined; -}): ManagedPythinkerLoginAuth { + readonly env?: ManagedKimiEnv | undefined; +}): ManagedKimiLoginAuth { const env = options.env ?? process.env; - const envBaseUrl = pythinkerCodeEnvBaseUrl(env); - const envOAuthHost = pythinkerCodeEnvOAuthHost(env); + const envBaseUrl = kimiCodeEnvBaseUrl(env); + const envOAuthHost = kimiCodeEnvOAuthHost(env); const hasOverride = options.requestedBaseUrl !== undefined || options.requestedOAuthHost !== undefined || @@ -387,7 +387,7 @@ export function resolvePythinkerCodeLoginAuth(options: { const configured = configuredOAuthRef(options.configuredOAuthRef); if (configured === undefined) return { baseUrl, oauthHost }; - const expectedKey = resolvePythinkerCodeOAuthKey({ + const expectedKey = resolveKimiCodeOAuthKey({ oauthHost: configured.oauthHost, baseUrl, }); @@ -396,13 +396,13 @@ export function resolvePythinkerCodeLoginAuth(options: { : { baseUrl, oauthHost }; } -function toModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefined { +function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { return undefined; } const contextLength = Number(item['context_length']); if (!Number.isInteger(contextLength) || contextLength <= 0) { - throw new Error(`Pythinker Code model "${item['id']}" must include a positive context_length.`); + throw new Error(`Kimi Code model "${item['id']}" must include a positive context_length.`); } const displayName = item['display_name']; const normalizedDisplayName = @@ -439,13 +439,13 @@ export function parseSupportsThinkingType(value: unknown): SupportsThinkingType } /** - * Lists the managed Pythinker Code models for an access token. Throws - * ManagedPythinkerCodeModelsAuthError on 401/402/403 so callers can trigger + * Lists the managed Kimi Code models for an access token. Throws + * ManagedKimiCodeModelsAuthError on 401/402/403 so callers can trigger * re-login. */ -export async function fetchManagedPythinkerCodeModels( - options: FetchManagedPythinkerCodeModelsOptions, -): Promise { +export async function fetchManagedKimiCodeModels( + options: FetchManagedKimiCodeModelsOptions, +): Promise { const fetchImpl = options.fetchImpl ?? fetch; const baseUrl = defaultBaseUrl(options.baseUrl); const response = await fetchImpl(`${baseUrl}/models`, { @@ -457,10 +457,10 @@ export async function fetchManagedPythinkerCodeModels( if (!response.ok) { const message = await readApiErrorMessage( response, - `Failed to list Pythinker Code models (HTTP ${response.status}).`, + `Failed to list Kimi Code models (HTTP ${response.status}).`, ); if (response.status === 401 || response.status === 402 || response.status === 403) { - throw new ManagedPythinkerCodeModelsAuthError({ + throw new ManagedKimiCodeModelsAuthError({ status: response.status, baseUrl, message, @@ -474,7 +474,7 @@ export async function fetchManagedPythinkerCodeModels( } return payload['data'] .map((item) => toModelInfo(item)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -482,18 +482,18 @@ export async function fetchManagedPythinkerCodeModels( * model into the config in place. The current default is preserved when * preserveDefaultModel is set and it still exists in the new catalog. */ -export function applyManagedPythinkerCodeConfig( - config: ManagedPythinkerConfigShape, +export function applyManagedKimiCodeConfig( + config: ManagedKimiConfigShape, options: { - readonly models: readonly ManagedPythinkerCodeModelInfo[]; + readonly models: readonly ManagedKimiCodeModelInfo[]; readonly baseUrl?: string | undefined; readonly oauthKey?: string | undefined; readonly oauthHost?: string | undefined; readonly preserveDefaultModel?: boolean | undefined; }, -): ManagedPythinkerCodeApplyResult { +): ManagedKimiCodeApplyResult { if (options.models.length === 0) { - throw new Error('No models available for Pythinker Code.'); + throw new Error('No models available for Kimi Code.'); } for (const model of options.models) { assertPositiveContextLength(model); @@ -503,13 +503,13 @@ export function applyManagedPythinkerCodeConfig( const oauth = options.oauthKey !== undefined ? managedOAuthRef({ key: options.oauthKey, oauthHost: options.oauthHost }) - : resolvePythinkerCodeOAuthRef({ baseUrl, oauthHost: options.oauthHost }); + : resolveKimiCodeOAuthRef({ baseUrl, oauthHost: options.oauthHost }); const existingModels = config.models ?? {}; const selectedDefault = selectDefaultModel(config, options.models, { preserveExisting: options.preserveDefaultModel === true, }); - config.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + config.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', baseUrl, apiKey: '', @@ -517,14 +517,14 @@ export function applyManagedPythinkerCodeConfig( }; for (const [key, model] of Object.entries(existingModels)) { - if (isRecord(model) && model['provider'] === PYTHINKER_CODE_PROVIDER_NAME) { + if (isRecord(model) && model['provider'] === KIMI_CODE_PROVIDER_NAME) { delete existingModels[key]; } } for (const model of options.models) { const capabilities = capabilitiesForModel(model); existingModels[managedModelKey(model.id)] = { - provider: PYTHINKER_CODE_PROVIDER_NAME, + provider: KIMI_CODE_PROVIDER_NAME, model: model.id, maxContextSize: model.contextLength, capabilities, @@ -562,13 +562,13 @@ export function applyManagedPythinkerCodeConfig( * Removes the managed provider and its aliases/services from the config, * clearing defaults that pointed at them. */ -export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerConfigShape): void { - delete config.providers[PYTHINKER_CODE_PROVIDER_NAME]; +export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape): void { + delete config.providers[KIMI_CODE_PROVIDER_NAME]; let removedDefaultModel = false; const existingModels = config.models ?? {}; for (const [key, model] of Object.entries(existingModels)) { - if (!isRecord(model) || model['provider'] !== PYTHINKER_CODE_PROVIDER_NAME) continue; + if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; delete existingModels[key]; if (config.defaultModel === key) removedDefaultModel = true; } @@ -578,7 +578,7 @@ export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerCo config.defaultModel = undefined; } - if (config['defaultProvider'] === PYTHINKER_CODE_PROVIDER_NAME) { + if (config['defaultProvider'] === KIMI_CODE_PROVIDER_NAME) { config['defaultProvider'] = undefined; } @@ -596,7 +596,7 @@ export function applyManagedPythinkerCodeLogoutConfig(config: ManagedPythinkerCo // must never end up with thinking off, and a non-thinking model ('no') must // never end up with thinking on. function forcedThinking( - model: ManagedPythinkerCodeModelInfo | undefined, + model: ManagedKimiCodeModelInfo | undefined, fallback: boolean, ): boolean { if (model?.supportsThinkingType === 'only') return true; @@ -605,13 +605,13 @@ function forcedThinking( } function selectDefaultModel( - config: ManagedPythinkerConfigShape, - models: readonly ManagedPythinkerCodeModelInfo[], + config: ManagedKimiConfigShape, + models: readonly ManagedKimiCodeModelInfo[], options: { readonly preserveExisting: boolean }, ): SelectedDefaultModel { const firstModel = models[0]; if (firstModel === undefined) { - throw new Error('No models available for Pythinker Code.'); + throw new Error('No models available for Kimi Code.'); } const managedModels = new Map(models.map((model) => [managedModelKey(model.id), model])); @@ -643,30 +643,30 @@ function selectDefaultModel( } function canPreserveDefaultModel( - existingModels: Record>, + existingModels: Record>, defaultModel: string, - managedModels: ReadonlyMap, + managedModels: ReadonlyMap, ): boolean { if (managedModels.has(defaultModel)) return true; const existing = existingModels[defaultModel]; - return isRecord(existing) && existing['provider'] !== PYTHINKER_CODE_PROVIDER_NAME; + return isRecord(existing) && existing['provider'] !== KIMI_CODE_PROVIDER_NAME; } /** * Removes the managed provider, model aliases, and services from the config * and reports what was actually removed. */ -export function clearManagedPythinkerCodeConfig( - config: ManagedPythinkerConfigShape, -): ManagedPythinkerCodeCleanupResult { - const removedProvider = Object.hasOwn(config.providers, PYTHINKER_CODE_PROVIDER_NAME); - delete config.providers[PYTHINKER_CODE_PROVIDER_NAME]; +export function clearManagedKimiCodeConfig( + config: ManagedKimiConfigShape, +): ManagedKimiCodeCleanupResult { + const removedProvider = Object.hasOwn(config.providers, KIMI_CODE_PROVIDER_NAME); + delete config.providers[KIMI_CODE_PROVIDER_NAME]; const removedModels: string[] = []; const models = config.models; if (models !== undefined) { for (const [key, model] of Object.entries(models)) { - if (!isRecord(model) || model['provider'] !== PYTHINKER_CODE_PROVIDER_NAME) continue; + if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; delete models[key]; removedModels.push(key); } @@ -692,7 +692,7 @@ export function clearManagedPythinkerCodeConfig( } return { - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, removedProvider, removedModels, defaultModelCleared, @@ -700,26 +700,26 @@ export function clearManagedPythinkerCodeConfig( }; } -function assertPositiveContextLength(model: ManagedPythinkerCodeModelInfo): void { +function assertPositiveContextLength(model: ManagedKimiCodeModelInfo): void { if (!Number.isInteger(model.contextLength) || model.contextLength <= 0) { - throw new Error(`Pythinker Code model "${model.id}" must include a positive context_length.`); + throw new Error(`Kimi Code model "${model.id}" must include a positive context_length.`); } } -export async function provisionManagedPythinkerCodeConfigAfterLogin( - options: ProvisionManagedPythinkerCodeConfigOptions, -): Promise { - return provisionManagedPythinkerCodeConfig(options); +export async function provisionManagedKimiCodeConfigAfterLogin( + options: ProvisionManagedKimiCodeConfigOptions, +): Promise { + return provisionManagedKimiCodeConfig(options); } /** * Full login-completion flow: fetches models, applies the provider config via * the adapter, and persists it. Returns the provisioned provider summary. */ -export async function provisionManagedPythinkerCodeConfig( - options: ProvisionManagedPythinkerCodeConfigOptions, -): Promise { - const models = await fetchManagedPythinkerCodeModels(options); +export async function provisionManagedKimiCodeConfig( + options: ProvisionManagedKimiCodeConfigOptions, +): Promise { + const models = await fetchManagedKimiCodeModels(options); const config = await options.adapter.read(); const applied = options.adapter.apply(config, { models, @@ -730,7 +730,7 @@ export async function provisionManagedPythinkerCodeConfig( }); await options.adapter.write(config); return { - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, defaultModel: applied.defaultModel, defaultThinking: applied.defaultThinking, models, diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index ac998ac2..04c4e818 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -1,7 +1,7 @@ /** * Managed-platform usage fetch / parse. * - * Only `managed:pythinker-code` is supported today. The platform exposes a + * Only `managed:kimi-code` is supported today. The platform exposes a * `/usages` endpoint that returns a payload of the shape: * * { @@ -21,21 +21,21 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; const MANAGED_PREFIX = 'managed:'; -const PYTHINKER_CODE_PLATFORM_ID = 'pythinker-code'; -export const DEFAULT_PYTHINKER_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; +const KIMI_CODE_PLATFORM_ID = 'kimi-code'; +export const DEFAULT_KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; -export function isManagedPythinkerCode(providerKey?: string | null): boolean { +export function isManagedKimiCode(providerKey?: string | null): boolean { if (!providerKey) return false; if (!providerKey.startsWith(MANAGED_PREFIX)) return false; - return providerKey.slice(MANAGED_PREFIX.length) === PYTHINKER_CODE_PLATFORM_ID; + return providerKey.slice(MANAGED_PREFIX.length) === KIMI_CODE_PLATFORM_ID; } -export function pythinkerCodeBaseUrl(): string { - return process.env['PYTHINKER_CODE_BASE_URL'] ?? DEFAULT_PYTHINKER_CODE_BASE_URL; +export function kimiCodeBaseUrl(): string { + return process.env['PYTHINKER_CODE_BASE_URL'] ?? DEFAULT_KIMI_CODE_BASE_URL; } -export function pythinkerCodeUsageUrl(): string { - return `${pythinkerCodeBaseUrl().replace(/\/+$/, '')}/usages`; +export function kimiCodeUsageUrl(): string { + return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/usages`; } export interface UsageRow { @@ -219,7 +219,7 @@ export async function fetchManagedUsage( status === 401 ? 'Authorization failed. Please check your API key (try /login).' : status === 404 - ? 'Usage endpoint not available. Try Pythinker For Coding.' + ? 'Usage endpoint not available. Try Kimi For Coding.' : `Failed to fetch usage: HTTP ${String(status)}`; return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; } diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 03c4e535..5ada7d12 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,12 +1,12 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import { parseSupportsThinkingType } from './managed-pythinker-code'; +import { parseSupportsThinkingType } from './managed-kimi-code'; import type { - ManagedPythinkerCodeModelInfo, - ManagedPythinkerConfigShape, -} from './managed-pythinker-code'; + ManagedKimiCodeModelInfo, + ManagedKimiConfigShape, +} from './managed-kimi-code'; -export type { ManagedPythinkerConfigShape }; +export type { ManagedKimiConfigShape }; export type LoginPlatformProviderType = 'pythinker' | 'openai' | 'anthropic' | 'openai_responses'; @@ -104,7 +104,7 @@ export function isOpenPlatformId(id: string): boolean { function toModelInfo( item: unknown, platform: OpenPlatformDefinition, -): ManagedPythinkerCodeModelInfo | undefined { +): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { return undefined; } @@ -139,7 +139,7 @@ function toModelInfo( * Derives kosong capability strings from a model info entry; undefined when * the model declares no capabilities. */ -export function capabilitiesForModel(model: ManagedPythinkerCodeModelInfo): string[] | undefined { +export function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { const caps = new Set(); // supports_thinking_type is the full three-state declaration and wins over // the legacy supports_reasoning boolean; absent (older servers) falls back. @@ -185,7 +185,7 @@ export async function fetchOpenPlatformModels( apiKey: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal, -): Promise { +): Promise { const baseUrl = platform.baseUrl; if (baseUrl === undefined || baseUrl.length === 0) { throw new Error(`Platform "${platform.id}" has no baseUrl for remote model listing.`); @@ -209,7 +209,7 @@ export async function fetchOpenPlatformModels( } return payload['data'] .map((item) => toModelInfo(item, platform)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -217,9 +217,9 @@ export async function fetchOpenPlatformModels( * prefixes; returns the full list when no prefixes are configured. */ export function filterModelsByPrefix( - models: ManagedPythinkerCodeModelInfo[], + models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, -): ManagedPythinkerCodeModelInfo[] { +): ManagedKimiCodeModelInfo[] { if (!platform.allowedPrefixes || platform.allowedPrefixes.length === 0) { return models; } @@ -238,11 +238,11 @@ export interface ApplyOpenPlatformResult { * default model to the selected one. */ export function applyOpenPlatformConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, options: { readonly platform: OpenPlatformDefinition; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; - readonly selectedModel: ManagedPythinkerCodeModelInfo; + readonly models: readonly ManagedKimiCodeModelInfo[]; + readonly selectedModel: ManagedKimiCodeModelInfo; readonly thinking: boolean; readonly apiKey: string; }, @@ -286,7 +286,7 @@ export function applyOpenPlatformConfig( * that pointed at them. */ export function removeOpenPlatformConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, platformId: string, ): void { delete config.providers[platformId]; diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts index a13ea9d3..5067fd74 100644 --- a/packages/oauth/src/openai-codex-oauth.ts +++ b/packages/oauth/src/openai-codex-oauth.ts @@ -3,10 +3,10 @@ import { createServer, type Server } from 'node:http'; import { readApiErrorMessage } from './api-error'; import type { - ManagedPythinkerCodeModelInfo, - ManagedPythinkerConfigShape, -} from './managed-pythinker-code'; -import { parseSupportsThinkingType } from './managed-pythinker-code'; + ManagedKimiCodeModelInfo, + ManagedKimiConfigShape, +} from './managed-kimi-code'; +import { parseSupportsThinkingType } from './managed-kimi-code'; import { renderOAuthErrorPage, renderOpenAICodexOAuthSuccessPage } from './oauth-pages'; import { capabilitiesForModel } from './open-platform'; import { isRecord } from './utils'; @@ -442,7 +442,7 @@ function readCodexFastMode(item: Record): boolean { }); } -function toCodexModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefined { +function toCodexModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { if (!isRecord(item)) return undefined; const id = @@ -506,7 +506,7 @@ function toCodexModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefi }; } -function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInfo[] { +function parseCodexModelsPayload(payload: unknown): ManagedKimiCodeModelInfo[] { if (!isRecord(payload)) { throw new Error(`Unexpected models response for ${CODEX_BASE_URL}.`); } @@ -522,7 +522,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInf return rawModels .map((item) => toCodexModelInfo(item)) - .filter((item): item is ManagedPythinkerCodeModelInfo => item !== undefined); + .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); } /** @@ -531,7 +531,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedPythinkerCodeModelInf */ export async function fetchOpenAICodexModels( options: FetchOpenAICodexModelsOptions, -): Promise { +): Promise { const fetchImpl = options.fetchImpl ?? fetch; const modelsUrl = `${CODEX_BASE_URL}/models?client_version=${encodeURIComponent(OPENAI_CODEX_CLI_CLIENT_VERSION)}`; const response = await fetchImpl(modelsUrl, { @@ -565,13 +565,13 @@ export interface ApplyOpenAICodexOAuthResult { * reasoning effort into the config in place. */ export function applyOpenAICodexOAuthConfig( - config: ManagedPythinkerConfigShape, + config: ManagedKimiConfigShape, options: { readonly accessToken: string; readonly accountId?: string | undefined; readonly refreshToken?: string | undefined; - readonly models: readonly ManagedPythinkerCodeModelInfo[]; - readonly selectedModel: ManagedPythinkerCodeModelInfo; + readonly models: readonly ManagedKimiCodeModelInfo[]; + readonly selectedModel: ManagedKimiCodeModelInfo; readonly thinking?: boolean | undefined; }, ): ApplyOpenAICodexOAuthResult { diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 904097c6..2eec1f62 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -1,26 +1,26 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { PYTHINKER_CODE_FLOW_CONFIG } from './constants'; +import { KIMI_CODE_FLOW_CONFIG } from './constants'; import { OAuthUnauthorizedError } from './errors'; import { assertPythinkerHostIdentity, createPythinkerDeviceHeaders, type PythinkerHostIdentity } from './identity'; import { fetchSubmitFeedback, - pythinkerCodeFeedbackUrl, + kimiCodeFeedbackUrl, type FetchSubmitFeedbackResult, type SubmitFeedbackBody, } from './managed-feedback'; import { - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PROVIDER_NAME, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeOAuthKey, - type ManagedPythinkerCodeProvisionResult, - type ManagedPythinkerConfigAdapter, -} from './managed-pythinker-code'; + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PROVIDER_NAME, + provisionManagedKimiCodeConfig, + resolveKimiCodeOAuthKey, + type ManagedKimiCodeProvisionResult, + type ManagedKimiConfigAdapter, +} from './managed-kimi-code'; import { fetchManagedUsage, - pythinkerCodeUsageUrl, + kimiCodeUsageUrl, type FetchManagedUsageError, type ParsedManagedUsage, } from './managed-usage'; @@ -47,7 +47,7 @@ export interface PythinkerOAuthToolkitOptions { readonly credentialsDir?: string | undefined; readonly storage?: TokenStorage | undefined; readonly flowConfig?: OAuthFlowConfig | undefined; - readonly configAdapter?: ManagedPythinkerConfigAdapter | undefined; + readonly configAdapter?: ManagedKimiConfigAdapter | undefined; readonly fetchImpl?: typeof fetch | undefined; readonly now?: OAuthManagerOptions['now']; readonly sleep?: OAuthManagerOptions['sleep']; @@ -71,7 +71,7 @@ export interface PythinkerOAuthTokenRef { export interface PythinkerOAuthLoginResult { readonly providerName: string; readonly ok: true; - readonly provision?: ManagedPythinkerCodeProvisionResult | undefined; + readonly provision?: ManagedKimiCodeProvisionResult | undefined; } export interface PythinkerOAuthLogoutResult { @@ -92,7 +92,7 @@ export class PythinkerOAuthToolkit { private readonly identity: PythinkerHostIdentity | undefined; private readonly storage: TokenStorage; private readonly flowConfig: OAuthFlowConfig; - private readonly configAdapter: ManagedPythinkerConfigAdapter | undefined; + private readonly configAdapter: ManagedKimiConfigAdapter | undefined; private readonly fetchImpl: typeof fetch | undefined; private readonly managerOptions: Pick< OAuthManagerOptions, @@ -106,7 +106,7 @@ export class PythinkerOAuthToolkit { this.homeDir = options.homeDir ?? defaultPythinkerHome(); const credentialsDir = options.credentialsDir ?? join(this.homeDir, 'credentials'); this.storage = options.storage ?? new FileTokenStorage(credentialsDir); - this.flowConfig = options.flowConfig ?? PYTHINKER_CODE_FLOW_CONFIG; + this.flowConfig = options.flowConfig ?? KIMI_CODE_FLOW_CONFIG; this.configAdapter = options.configAdapter; this.fetchImpl = options.fetchImpl; this.managerOptions = { @@ -122,7 +122,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return { @@ -139,7 +139,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, options: PythinkerOAuthLoginOptions = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(options.oauthRef, options.oauthHost); const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(options.baseUrl, oauthHost); const manager = this.managerFor(name, oauthKey, oauthHost); @@ -168,10 +168,10 @@ export class PythinkerOAuthToolkit { const shouldProvision = options.provisionConfig ?? this.configAdapter !== undefined; const configAdapter = this.configAdapter; - let provision: ManagedPythinkerCodeProvisionResult | undefined; + let provision: ManagedKimiCodeProvisionResult | undefined; if (shouldProvision && configAdapter !== undefined) { - const provisionWithToken = (token: string): Promise => - provisionManagedPythinkerCodeConfig({ + const provisionWithToken = (token: string): Promise => + provisionManagedKimiCodeConfig({ accessToken: token, adapter: configAdapter, baseUrl: options.baseUrl, @@ -211,11 +211,11 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); await this.managerFor(name, oauthKey, oauthHost).logout(); - if (this.configAdapter?.remove !== undefined && name === PYTHINKER_CODE_PROVIDER_NAME) { + if (this.configAdapter?.remove !== undefined && name === KIMI_CODE_PROVIDER_NAME) { const config = await this.configAdapter.read(); this.configAdapter.remove(config); await this.configAdapter.write(config); @@ -230,7 +230,7 @@ export class PythinkerOAuthToolkit { readonly oauthRef?: PythinkerOAuthTokenRef | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(options.oauthRef); const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return this.managerFor(name, oauthKey, oauthHost).ensureFresh(options); @@ -240,7 +240,7 @@ export class PythinkerOAuthToolkit { providerName?: string, oauthRef?: PythinkerOAuthTokenRef, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return this.managerFor(name, oauthKey, oauthHost).getCachedAccessToken(); @@ -250,7 +250,7 @@ export class PythinkerOAuthToolkit { providerName?: string | undefined, oauthRef?: PythinkerOAuthTokenRef | undefined, ): BearerTokenProvider { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); return { @@ -265,7 +265,7 @@ export class PythinkerOAuthToolkit { readonly baseUrl?: string | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; try { const accessToken = await this.ensureFresh(name, { oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), @@ -293,7 +293,7 @@ export class PythinkerOAuthToolkit { readonly baseUrl?: string | undefined; } = {}, ): Promise { - const name = providerName ?? PYTHINKER_CODE_PROVIDER_NAME; + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; try { const accessToken = await this.ensureFresh(name, { oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), @@ -309,7 +309,7 @@ export class PythinkerOAuthToolkit { managerFor( providerName: string, - oauthKey = PYTHINKER_CODE_OAUTH_KEY, + oauthKey = KIMI_CODE_OAUTH_KEY, oauthHost?: string | undefined, ): OAuthManager { const storageName = resolvePythinkerTokenStorageName({ providerName, oauthKey }); @@ -345,7 +345,7 @@ export class PythinkerOAuthToolkit { baseUrl?: string | undefined, oauthHost?: string | undefined, ): string { - return resolvePythinkerCodeOAuthKey({ + return resolveKimiCodeOAuthKey({ oauthHost: oauthHost ?? this.flowConfig.oauthHost, baseUrl, }); @@ -370,13 +370,13 @@ export function resolvePythinkerTokenStorageName(input: { readonly providerName?: string | undefined; readonly oauthKey?: string | undefined; }): string { - const providerName = input.providerName ?? PYTHINKER_CODE_PROVIDER_NAME; - if (providerName !== PYTHINKER_CODE_PROVIDER_NAME) { + const providerName = input.providerName ?? KIMI_CODE_PROVIDER_NAME; + if (providerName !== KIMI_CODE_PROVIDER_NAME) { throw new Error(`No OAuth manager configured for provider "${providerName}".`); } - const key = input.oauthKey ?? PYTHINKER_CODE_OAUTH_KEY; - if (key === 'pythinker-code' || key === PYTHINKER_CODE_OAUTH_KEY) return 'pythinker-code'; + const key = input.oauthKey ?? KIMI_CODE_OAUTH_KEY; + if (key === 'kimi-code' || key === KIMI_CODE_OAUTH_KEY) return 'kimi-code'; const prefix = 'oauth/'; if (key.startsWith(prefix) && key.slice(prefix.length).length > 0) { @@ -394,12 +394,12 @@ function defaultPythinkerHome(): string { } function managedUsageUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return pythinkerCodeUsageUrl(); + if (baseUrl === undefined) return kimiCodeUsageUrl(); return `${baseUrl.replace(/\/+$/, '')}/usages`; } function managedFeedbackUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return pythinkerCodeFeedbackUrl(); + if (baseUrl === undefined) return kimiCodeFeedbackUrl(); return `${baseUrl.replace(/\/+$/, '')}/feedback`; } diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index dcb38d07..c370c5d2 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -36,7 +36,7 @@ export interface DeviceAuthorization { /** OAuth flow endpoint + client configuration. */ export interface OAuthFlowConfig { - /** Logical provider name for storage (e.g. "pythinker-code"). */ + /** Logical provider name for storage (e.g. "kimi-code"). */ readonly name: string; /** Base URL of the OAuth server, no trailing slash. */ readonly oauthHost: string; diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index f8840c88..d894fd9e 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -11,7 +11,7 @@ import { removeCustomRegistryProvider, type CustomRegistryProviderEntry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '../src/custom-registry'; function makeKokubResponseBody(): Record { @@ -184,7 +184,7 @@ describe('fetchCustomRegistry', () => { describe('applyCustomRegistryProvider', () => { it('writes provider + model aliases for a kokub-shaped entry with default fallbacks', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'registry_chat-completions', name: 'Sample Registry (chat completions)', @@ -224,7 +224,7 @@ describe('applyCustomRegistryProvider', () => { }); it('falls back to the model id for displayName when name is absent', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'demo', name: 'Demo', @@ -245,7 +245,7 @@ describe('applyCustomRegistryProvider', () => { }); it('derives rich capabilities and limit-based context size when rich fields are present', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -279,7 +279,7 @@ describe('applyCustomRegistryProvider', () => { }); it('clears stale aliases for the same provider before re-populating', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -323,7 +323,7 @@ describe('applyCustomRegistryProvider', () => { describe('removeCustomRegistryProvider', () => { it('removes the provider and every alias for it, and clears matching defaultModel', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -359,7 +359,7 @@ describe('removeCustomRegistryProvider', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -401,7 +401,7 @@ describe('applyCustomRegistryEntries', () => { c: { id: 'c', name: 'C', api: 'https://c.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; applyCustomRegistryEntries(config, entries, source); applyCustomRegistryEntries(config, entries, source); @@ -417,7 +417,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -453,7 +453,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -507,7 +507,7 @@ describe('applyCustomRegistryEntries', () => { b: { id: 'b', name: 'B', api: 'https://b.test/v1', type: 'openai', models: { m1: { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { // Provider from an unrelated source — must not be touched. keepme: { @@ -564,7 +564,7 @@ describe('applyCustomRegistryEntries', () => { apiKey: 'sk-b', }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; applyCustomRegistryEntries( config, { diff --git a/packages/oauth/test/managed-feedback.test.ts b/packages/oauth/test/managed-feedback.test.ts index 7c7cdd6f..ca61ee36 100644 --- a/packages/oauth/test/managed-feedback.test.ts +++ b/packages/oauth/test/managed-feedback.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchSubmitFeedback, - pythinkerCodeFeedbackUrl, + kimiCodeFeedbackUrl, type SubmitFeedbackBody, } from '../src/managed-feedback'; @@ -19,14 +19,14 @@ const SAMPLE_BODY: SubmitFeedbackBody = { model: 'pythinker-code/pythinker-for-coding', }; -describe('pythinkerCodeFeedbackUrl', () => { +describe('kimiCodeFeedbackUrl', () => { it('appends /feedback to the default base URL', () => { - expect(pythinkerCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback'); + expect(kimiCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback'); }); it('honours PYTHINKER_CODE_BASE_URL and trims trailing slashes', () => { vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://example.test/v9///'); - expect(pythinkerCodeFeedbackUrl()).toBe('https://example.test/v9/feedback'); + expect(kimiCodeFeedbackUrl()).toBe('https://example.test/v9/feedback'); }); }); diff --git a/packages/oauth/test/managed-pythinker-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts similarity index 74% rename from packages/oauth/test/managed-pythinker-code.test.ts rename to packages/oauth/test/managed-kimi-code.test.ts index 6bc1f14c..b4abc784 100644 --- a/packages/oauth/test/managed-pythinker-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; import { - applyManagedPythinkerCodeLogoutConfig, - applyManagedPythinkerCodeConfig, - clearManagedPythinkerCodeConfig, - fetchManagedPythinkerCodeModels, - PYTHINKER_CODE_OAUTH_KEY, - PYTHINKER_CODE_PROVIDER_NAME, - ManagedPythinkerCodeModelsAuthError, - provisionManagedPythinkerCodeConfig, - resolvePythinkerCodeLoginAuth, - resolvePythinkerCodeOAuthKey, - resolvePythinkerCodeOAuthRef, - resolvePythinkerCodeRuntimeAuth, - type ManagedPythinkerConfigShape, -} from '../src/managed-pythinker-code'; + applyManagedKimiCodeLogoutConfig, + applyManagedKimiCodeConfig, + clearManagedKimiCodeConfig, + fetchManagedKimiCodeModels, + KIMI_CODE_OAUTH_KEY, + KIMI_CODE_PROVIDER_NAME, + ManagedKimiCodeModelsAuthError, + provisionManagedKimiCodeConfig, + resolveKimiCodeLoginAuth, + resolveKimiCodeOAuthKey, + resolveKimiCodeOAuthRef, + resolveKimiCodeRuntimeAuth, + type ManagedKimiConfigShape, +} from '../src/managed-kimi-code'; import { OAuthUnauthorizedError } from '../src/errors'; function makeModelsResponse(): Response { @@ -43,26 +43,26 @@ function makeModelsResponse(): Response { ); } -describe('provisionManagedPythinkerCodeConfig', () => { +describe('provisionManagedKimiCodeConfig', () => { it('keeps the legacy credential key for the default production environment', () => { expect( - resolvePythinkerCodeOAuthKey({ + resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.kimi.com/', baseUrl: 'https://api.kimi.com/coding/v1/', }), - ).toBe(PYTHINKER_CODE_OAUTH_KEY); + ).toBe(KIMI_CODE_OAUTH_KEY); }); it('scopes credential keys for non-default OAuth hosts and API base URLs', () => { - const devKey = resolvePythinkerCodeOAuthKey({ + const devKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); - expect(devKey).not.toBe(PYTHINKER_CODE_OAUTH_KEY); - expect(devKey).toMatch(/^oauth\/pythinker-code-env-[a-f0-9]{16}$/); + expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY); + expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/); expect( - resolvePythinkerCodeOAuthKey({ + resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test/', baseUrl: 'https://api.dev.example.test/coding/v1/', }), @@ -71,20 +71,20 @@ describe('provisionManagedPythinkerCodeConfig', () => { it('derives a full OAuth ref whose key and persisted host stay in sync', () => { // Default environment collapses to the legacy ref (no persisted host), so - // existing production credentials keep resolving to `pythinker-code.json`. + // existing production credentials keep resolving to `kimi-code.json`. expect( - resolvePythinkerCodeOAuthRef({ + resolveKimiCodeOAuthRef({ oauthHost: 'https://auth.kimi.com/', baseUrl: 'https://api.kimi.com/coding/v1/', }), - ).toEqual({ storage: 'file', key: PYTHINKER_CODE_OAUTH_KEY, oauthHost: undefined }); + ).toEqual({ storage: 'file', key: KIMI_CODE_OAUTH_KEY, oauthHost: undefined }); - const defaultAuthCustomApiRef = resolvePythinkerCodeOAuthRef({ + const defaultAuthCustomApiRef = resolveKimiCodeOAuthRef({ baseUrl: 'https://api.example.test/coding/v1', }); expect(defaultAuthCustomApiRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.kimi.com', baseUrl: 'https://api.example.test/coding/v1', }), @@ -93,13 +93,13 @@ describe('provisionManagedPythinkerCodeConfig', () => { // A non-default environment yields a scoped key AND the normalized host, // both derived from the same input — login and runtime cannot drift apart. - const devRef = resolvePythinkerCodeOAuthRef({ + const devRef = resolveKimiCodeOAuthRef({ oauthHost: 'https://auth.dev.example.test/', baseUrl: 'https://api.dev.example.test/coding/v1', }); expect(devRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }), @@ -111,11 +111,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; const envBaseUrl = 'https://api.env.example.test/coding/v1/'; const envOauthHost = 'https://auth.env.example.test/'; - const configuredOAuthRef = resolvePythinkerCodeOAuthRef({ + const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl, }); - const auth = resolvePythinkerCodeRuntimeAuth({ + const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl, configuredOAuthRef, env: { @@ -127,7 +127,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { expect(auth.baseUrl).toBe('https://api.env.example.test/coding/v1'); expect(auth.oauthRef).toEqual({ storage: 'file', - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.env.example.test', baseUrl: 'https://api.env.example.test/coding/v1', }), @@ -139,7 +139,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { const baseUrl = 'https://api.dev.example.test/coding/v1'; const configuredOAuthRef = { storage: 'keyring' as const, - key: resolvePythinkerCodeOAuthKey({ + key: resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl, }), @@ -147,7 +147,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }; expect( - resolvePythinkerCodeRuntimeAuth({ + resolveKimiCodeRuntimeAuth({ configuredBaseUrl: baseUrl, configuredOAuthRef, env: {}, @@ -160,10 +160,10 @@ describe('provisionManagedPythinkerCodeConfig', () => { it('resolves login auth without reusing persisted refs under explicit or env overrides', () => { const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; - const configuredOAuthRef = resolvePythinkerCodeOAuthRef({ baseUrl: configuredBaseUrl }); + const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl }); expect( - resolvePythinkerCodeLoginAuth({ + resolveKimiCodeLoginAuth({ configuredBaseUrl, configuredOAuthRef, requestedBaseUrl: 'https://api.requested.example.test/coding/v1/', @@ -175,7 +175,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); expect( - resolvePythinkerCodeLoginAuth({ + resolveKimiCodeLoginAuth({ configuredBaseUrl, configuredOAuthRef, env: {}, @@ -188,7 +188,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('writes the managed provider, models, services, and default model through an adapter', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -197,8 +197,8 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }, models: { - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', }, 'custom-default': { @@ -210,20 +210,20 @@ describe('provisionManagedPythinkerCodeConfig', () => { const write = vi.fn(); const fetchMock = vi.fn(async () => makeModelsResponse()); - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: fetchMock as unknown as typeof fetch, adapter: { configPath: '/tmp/config.toml', read: () => config, write, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, - defaultModel: 'pythinker-code/pythinker-for-coding', + providerName: KIMI_CODE_PROVIDER_NAME, + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, configPath: '/tmp/config.toml', }); @@ -249,39 +249,39 @@ describe('provisionManagedPythinkerCodeConfig', () => { apiKey: 'sk-existing', }); expect(config.models?.['custom-default']?.provider).toBe('custom'); - expect(config.models?.['pythinker-code/stale']).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.models?.['kimi-code/stale']).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ type: 'pythinker', baseUrl: 'https://api.kimi.com/coding/v1', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toMatchObject({ - provider: PYTHINKER_CODE_PROVIDER_NAME, + expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], displayName: 'Pythinker for Coding', }); - expect(config.models?.['pythinker-code/pythinker-k2.5']?.capabilities).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-k2.5']?.capabilities).toBeUndefined(); expect(config.services?.pythoughtsSearch).toMatchObject({ baseUrl: 'https://api.kimi.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }); expect(Object.keys(config.services ?? {})).toEqual(['pythoughtsSearch', 'pythoughtsFetch']); }); it('writes scoped OAuth refs when provisioning against a non-default environment', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', baseUrl: 'https://api.dev.example.test/coding/v1', }); - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', oauthKey, @@ -290,11 +290,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl: 'https://api.dev.example.test/coding/v1', oauth: { storage: 'file', @@ -315,24 +315,24 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('persists the default OAuth host when only the API base URL is scoped', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolvePythinkerCodeOAuthKey({ baseUrl }); + const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', baseUrl, fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toMatchObject({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ baseUrl, oauth: { storage: 'file', @@ -343,14 +343,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('preserves an existing valid default model during refresh', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', apiKey: 'sk-existing', baseUrl: 'https://example.test/v1', }, - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, @@ -363,22 +363,22 @@ describe('provisionManagedPythinkerCodeConfig', () => { model: 'custom-model', maxContextSize: 1000, }, - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', maxContextSize: 1000, }, }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -386,22 +386,22 @@ describe('provisionManagedPythinkerCodeConfig', () => { expect(result.defaultThinking).toBe(false); expect(config.defaultModel).toBe('custom-default'); expect(config.defaultThinking).toBe(false); - expect(config.models?.['pythinker-code/stale']).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']?.displayName).toBe('Pythinker for Coding'); + expect(config.models?.['kimi-code/stale']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Pythinker for Coding'); }); it('infers default_thinking from fresh managed model capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 1000, capabilities: [], @@ -409,24 +409,24 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('preserves explicit default_thinking when preserving a custom default without capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -444,14 +444,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -461,7 +461,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('defaults default_thinking to false when a preserved custom default has no signal', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -478,14 +478,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -495,7 +495,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('does not infer default_thinking from preserved custom default capabilities', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -513,14 +513,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -530,7 +530,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('keeps default_thinking off even when preserved custom default has thinking capability', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -548,14 +548,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); @@ -565,45 +565,45 @@ describe('provisionManagedPythinkerCodeConfig', () => { }); it('falls back to the first fetched model when the preserved default was removed', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/stale', + defaultModel: 'kimi-code/stale', defaultThinking: false, models: { - 'pythinker-code/stale': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/stale': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'stale', maxContextSize: 1000, }, }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(false); - expect(config.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(config.defaultThinking).toBe(false); }); it('removes managed provider, models, services, and default model on logout', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, @@ -612,11 +612,11 @@ describe('provisionManagedPythinkerCodeConfig', () => { apiKey: 'sk-existing', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: true, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -632,14 +632,14 @@ describe('provisionManagedPythinkerCodeConfig', () => { customService: { baseUrl: 'https://service.example.test' }, }, raw: { - default_model: 'pythinker-code/pythinker-for-coding', + default_model: 'kimi-code/pythinker-for-coding', providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { type: 'pythinker' }, + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' }, custom: { type: 'pythinker' }, }, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', }, 'custom-default': { @@ -654,12 +654,12 @@ describe('provisionManagedPythinkerCodeConfig', () => { }, }; - applyManagedPythinkerCodeLogoutConfig(config); + applyManagedKimiCodeLogoutConfig(config); expect(config.defaultModel).toBeUndefined(); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toBeDefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toBeDefined(); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); @@ -680,7 +680,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -697,7 +697,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -719,7 +719,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { ) as unknown as typeof fetch; await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), @@ -743,17 +743,17 @@ describe('provisionManagedPythinkerCodeConfig', () => { ), ) as unknown as typeof fetch; - const promise = fetchManagedPythinkerCodeModels({ + const promise = fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, }); await expect(promise).rejects.toThrow( - "Pythinker Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", + "Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", ); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', baseUrl: 'https://api.dev.example.test/coding/v1', fetchImpl, @@ -763,36 +763,36 @@ describe('provisionManagedPythinkerCodeConfig', () => { baseUrl: 'https://api.dev.example.test/coding/v1', }); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), ).rejects.toBeInstanceOf(OAuthUnauthorizedError); await expect( - fetchManagedPythinkerCodeModels({ + fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl, }), - ).rejects.toBeInstanceOf(ManagedPythinkerCodeModelsAuthError); + ).rejects.toBeInstanceOf(ManagedKimiCodeModelsAuthError); }); it('clears managed provider, models, default model, and services on logout', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, custom: { type: 'pythinker', apiKey: 'sk-existing', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, }, @@ -806,30 +806,30 @@ describe('provisionManagedPythinkerCodeConfig', () => { pythoughtsSearch: { baseUrl: 'https://api.kimi.com/coding/v1/search', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, pythoughtsFetch: { baseUrl: 'https://api.kimi.com/coding/v1/fetch', apiKey: '', - oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, otherService: { baseUrl: 'https://service.example.test' }, }, }; - const result = clearManagedPythinkerCodeConfig(config); + const result = clearManagedKimiCodeConfig(config); expect(result).toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, removedProvider: true, - removedModels: ['pythinker-code/pythinker-for-coding'], + removedModels: ['kimi-code/pythinker-for-coding'], defaultModelCleared: true, removedServices: ['pythoughtsSearch', 'pythoughtsFetch'], }); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]).toBeUndefined(); + expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); expect(config.defaultModel).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); expect(config.services?.pythoughtsSearch).toBeUndefined(); expect(config.services?.pythoughtsFetch).toBeUndefined(); @@ -874,7 +874,7 @@ describe('supports_thinking_type', () => { } it('parses supports_thinking_type from the models endpoint', async () => { - const models = await fetchManagedPythinkerCodeModels({ + const models = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, }); @@ -885,13 +885,13 @@ describe('supports_thinking_type', () => { }); it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => { - const absent = await fetchManagedPythinkerCodeModels({ + const absent = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, }); expect(absent[0]?.supportsThinkingType).toBeUndefined(); - const invalid = await fetchManagedPythinkerCodeModels({ + const invalid = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn( async () => @@ -914,20 +914,20 @@ describe('supports_thinking_type', () => { }); it('maps the three states onto capabilities, overriding supports_reasoning', async () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - await provisionManagedPythinkerCodeConfig({ + await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); // 'only' → thinking locked on. - expect(config.models?.['pythinker-code/pythinker-for-coding']?.capabilities).toEqual([ + expect(config.models?.['kimi-code/pythinker-for-coding']?.capabilities).toEqual([ 'thinking', 'always_thinking', 'image_in', @@ -935,18 +935,18 @@ describe('supports_thinking_type', () => { 'tool_use', ]); // 'no' → no thinking capability despite supports_reasoning=true. - expect(config.models?.['pythinker-code/pythinker-plain']?.capabilities).toEqual(['tool_use']); + expect(config.models?.['kimi-code/pythinker-plain']?.capabilities).toEqual(['tool_use']); // 'both' → plain toggleable thinking. - expect(config.models?.['pythinker-code/pythinker-toggle']?.capabilities).toEqual([ + expect(config.models?.['kimi-code/pythinker-toggle']?.capabilities).toEqual([ 'thinking', 'tool_use', ]); }); it('parses supported_reasoning_efforts and writes supportEfforts onto model aliases', async () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: ManagedKimiConfigShape = { providers: {} }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn( async () => @@ -976,54 +976,54 @@ describe('supports_thinking_type', () => { adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); expect(result.models[0]?.supportedReasoningEfforts).toEqual(['low', 'medium', 'high', 'max']); expect(result.models[1]?.supportedReasoningEfforts).toBeUndefined(); expect(result.models[2]?.supportedReasoningEfforts).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-for-coding']?.supportEfforts).toEqual([ + expect(config.models?.['kimi-code/pythinker-for-coding']?.supportEfforts).toEqual([ 'low', 'medium', 'high', 'max', ]); - expect(config.models?.['pythinker-code/pythinker-plain']?.supportEfforts).toBeUndefined(); - expect(config.models?.['pythinker-code/pythinker-invalid']?.supportEfforts).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-plain']?.supportEfforts).toBeUndefined(); + expect(config.models?.['kimi-code/pythinker-invalid']?.supportEfforts).toBeUndefined(); }); it('forces default thinking on when the selected default model is thinking-only', async () => { - const config: ManagedPythinkerConfigShape = { providers: {}, defaultThinking: false }; + const config: ManagedKimiConfigShape = { providers: {}, defaultThinking: false }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('forces default thinking on when preserving a thinking-only managed default', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-for-coding', + defaultModel: 'kimi-code/pythinker-for-coding', defaultThinking: false, models: { - 'pythinker-code/pythinker-for-coding': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-for-coding': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-for-coding', maxContextSize: 262144, capabilities: ['thinking'], @@ -1031,35 +1031,35 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-for-coding'); + expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); expect(result.defaultThinking).toBe(true); expect(config.defaultThinking).toBe(true); }); it('forces default thinking off when preserving a no-thinking managed default', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { - [PYTHINKER_CODE_PROVIDER_NAME]: { + [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker', apiKey: '', }, }, - defaultModel: 'pythinker-code/pythinker-plain', + defaultModel: 'kimi-code/pythinker-plain', defaultThinking: true, models: { - 'pythinker-code/pythinker-plain': { - provider: PYTHINKER_CODE_PROVIDER_NAME, + 'kimi-code/pythinker-plain': { + provider: KIMI_CODE_PROVIDER_NAME, model: 'pythinker-plain', maxContextSize: 128000, capabilities: ['thinking'], @@ -1067,24 +1067,24 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); - expect(result.defaultModel).toBe('pythinker-code/pythinker-plain'); + expect(result.defaultModel).toBe('kimi-code/pythinker-plain'); expect(result.defaultThinking).toBe(false); expect(config.defaultThinking).toBe(false); }); it('keeps a preserved non-managed default thinking selection untouched', async () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { custom: { type: 'pythinker', @@ -1102,14 +1102,14 @@ describe('supports_thinking_type', () => { }, }; - const result = await provisionManagedPythinkerCodeConfig({ + const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, preserveDefaultModel: true, adapter: { read: () => config, write: vi.fn(), - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 22175e61..09cfc7c9 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -4,7 +4,7 @@ import { fetchManagedUsage, formatDuration, formatResetTime, - isManagedPythinkerCode, + isManagedKimiCode, parseManagedUsagePayload, } from '../src/managed-usage'; @@ -12,14 +12,14 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('isManagedPythinkerCode', () => { +describe('isManagedKimiCode', () => { it('matches only the pythinker-code managed provider', () => { - expect(isManagedPythinkerCode('managed:pythinker-code')).toBe(true); - expect(isManagedPythinkerCode('managed:pythoughts-labs')).toBe(false); - expect(isManagedPythinkerCode('openai')).toBe(false); - expect(isManagedPythinkerCode('')).toBe(false); - expect(isManagedPythinkerCode(null)).toBe(false); - expect(isManagedPythinkerCode()).toBe(false); + expect(isManagedKimiCode('managed:kimi-code')).toBe(true); + expect(isManagedKimiCode('managed:pythoughts-labs')).toBe(false); + expect(isManagedKimiCode('openai')).toBe(false); + expect(isManagedKimiCode('')).toBe(false); + expect(isManagedKimiCode(null)).toBe(false); + expect(isManagedKimiCode()).toBe(false); }); }); @@ -152,7 +152,7 @@ describe('fetchManagedUsage', () => { expect(result.kind).toBe('error'); if (result.kind !== 'error') return; expect(result.status).toBe(404); - expect(result.message).toBe('Usage endpoint not available. Try Pythinker For Coding.'); + expect(result.message).toBe('Usage endpoint not available. Try Kimi For Coding.'); }); }); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index 14989d25..395b97e6 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -10,7 +10,7 @@ import { OPEN_PLATFORMS, OpenPlatformApiError, removeOpenPlatformConfig, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, } from '../src/open-platform'; function makeModelsResponse(): Response { @@ -155,7 +155,7 @@ describe('filterModelsByPrefix', () => { { id: 'gpt-4', contextLength: 1000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-pythinker-code').ManagedPythinkerCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); expect(filtered).toHaveLength(1); expect(filtered[0]?.id).toBe('kimi-k2-0712-preview'); }); @@ -171,7 +171,7 @@ describe('filterModelsByPrefix', () => { { id: 'model-b', contextLength: 2000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-pythinker-code').ManagedPythinkerCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); expect(filtered).toHaveLength(2); }); }); @@ -275,7 +275,7 @@ describe('capabilitiesForModel', () => { describe('applyOpenPlatformConfig', () => { it('writes provider, models, and defaults', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, }; const platform = getOpenPlatformById('moonshot-cn')!; @@ -315,7 +315,7 @@ describe('applyOpenPlatformConfig', () => { }); it('clears stale models for the same provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-old' }, }, @@ -344,7 +344,7 @@ describe('applyOpenPlatformConfig', () => { describe('removeOpenPlatformConfig', () => { it('removes provider, its models, and defaultModel when matched', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, 'other': { type: 'pythinker', baseUrl: 'https://other.test/v1', apiKey: 'sk-other' }, @@ -366,7 +366,7 @@ describe('removeOpenPlatformConfig', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, }, diff --git a/packages/oauth/test/openai-codex-oauth.test.ts b/packages/oauth/test/openai-codex-oauth.test.ts index 161bcc62..b736ec58 100644 --- a/packages/oauth/test/openai-codex-oauth.test.ts +++ b/packages/oauth/test/openai-codex-oauth.test.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; -import type { ManagedPythinkerConfigShape } from '../src/managed-pythinker-code'; +import type { ManagedKimiConfigShape } from '../src/managed-kimi-code'; import { applyOpenAICodexOAuthConfig, buildOpenAICodexAuthorizeUrl, @@ -200,7 +200,7 @@ describe('openai-codex-oauth', () => { }); const config = { providers: {}, - } as ManagedPythinkerConfigShape; + } as ManagedKimiConfigShape; const models = CODEX_MODELS_RESPONSE.models.map((model) => ({ id: model.slug, contextLength: model.context_window, @@ -245,7 +245,7 @@ describe('openai-codex-oauth', () => { }); it('uses the selected model highest supported effort when max is unavailable', () => { - const config: ManagedPythinkerConfigShape = { + const config: ManagedKimiConfigShape = { providers: {}, thinking: { mode: 'auto', effort: 'low' }, }; diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 27df6b15..e988e88e 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -3,12 +3,12 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { - applyManagedPythinkerCodeConfig, - PYTHINKER_CODE_PROVIDER_NAME, + applyManagedKimiCodeConfig, + KIMI_CODE_PROVIDER_NAME, PythinkerOAuthToolkit, - resolvePythinkerCodeOAuthKey, + resolveKimiCodeOAuthKey, resolvePythinkerTokenStorageName, - type ManagedPythinkerConfigShape, + type ManagedKimiConfigShape, type TokenInfo, type TokenStorage, } from '../src'; @@ -80,18 +80,18 @@ describe('resolvePythinkerTokenStorageName', () => { it('maps config oauth keys to the file storage token name', () => { expect( resolvePythinkerTokenStorageName({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, - oauthKey: 'oauth/pythinker-code', + providerName: KIMI_CODE_PROVIDER_NAME, + oauthKey: 'oauth/kimi-code', }), - ).toBe('pythinker-code'); - expect(resolvePythinkerTokenStorageName({ oauthKey: 'pythinker-code' })).toBe('pythinker-code'); + ).toBe('kimi-code'); + expect(resolvePythinkerTokenStorageName({ oauthKey: 'kimi-code' })).toBe('kimi-code'); }); it('rejects unsupported providers and unsafe token keys', () => { expect(() => resolvePythinkerTokenStorageName({ providerName: 'custom', - oauthKey: 'pythinker-code', + oauthKey: 'kimi-code', }), ).toThrow(/No OAuth manager/); expect(() => resolvePythinkerTokenStorageName({ oauthKey: '../pythinker-code' })).toThrow(/Invalid/); @@ -101,7 +101,7 @@ describe('resolvePythinkerTokenStorageName', () => { describe('PythinkerOAuthToolkit', () => { it('can be constructed without host identity', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); const toolkit = new PythinkerOAuthToolkit({ homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), storage, @@ -113,7 +113,7 @@ describe('PythinkerOAuthToolkit', () => { it('reports status and exposes a bearer token provider', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); const toolkit = new PythinkerOAuthToolkit({ homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), identity: TEST_IDENTITY, @@ -122,7 +122,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.status()).resolves.toEqual({ - providers: [{ providerName: PYTHINKER_CODE_PROVIDER_NAME, hasToken: true }], + providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], }); await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); }); @@ -139,7 +139,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }) + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }) .getAccessToken(), ).resolves.toBe('custom-access'); }); @@ -147,7 +147,7 @@ describe('PythinkerOAuthToolkit', () => { it('refreshes configured bearer token refs against their OAuth host', async () => { const storage = new MemoryTokenStorage(); const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl: 'https://api.dev.example.test/coding/v1', }); @@ -177,7 +177,7 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 1_000, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: 'https://auth.kimi.com', clientId: 'test-client-id', }, @@ -185,7 +185,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { key: oauthKey, oauthHost }) + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: oauthKey, oauthHost }) .getAccessToken(), ).resolves.toBe('rotated-dev-access'); }); @@ -217,7 +217,7 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 1_000, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: 'https://auth.kimi.com', clientId: 'test-client-id', }, @@ -225,7 +225,7 @@ describe('PythinkerOAuthToolkit', () => { await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code', oauthHost: 'https://auth.one.test/', }) @@ -233,7 +233,7 @@ describe('PythinkerOAuthToolkit', () => { ).resolves.toBe('rotated-1'); await expect( toolkit - .tokenProvider(PYTHINKER_CODE_PROVIDER_NAME, { + .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code', oauthHost: 'https://auth.two.test', }) @@ -248,7 +248,7 @@ describe('PythinkerOAuthToolkit', () => { it('returns the cached access token without refreshing it', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', { + storage.tokens.set('kimi-code', { ...token('cached-access'), expiresAt: 1, }); @@ -273,7 +273,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect( - toolkit.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }), + toolkit.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }), ).resolves.toBe('custom-cached-access'); }); @@ -303,7 +303,7 @@ describe('PythinkerOAuthToolkit', () => { read: () => config, write, apply: (target, input) => { - target.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + target.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', apiKey: '', }; @@ -315,9 +315,9 @@ describe('PythinkerOAuthToolkit', () => { }, }); - storage.tokens.set('pythinker-code', token('access-1')); + storage.tokens.set('kimi-code', token('access-1')); await expect(toolkit.login()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, provision: { defaultModel: 'pythinker-code/pythinker-for-coding', @@ -334,7 +334,7 @@ describe('PythinkerOAuthToolkit', () => { const onDeviceCode = vi.fn(); const config = { providers: {} }; const oauthHost = 'https://auth.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); storage.tokens.set(resolvePythinkerTokenStorageName({ oauthKey }), token('stale-access')); const fetchMock = vi .fn() @@ -373,7 +373,7 @@ describe('PythinkerOAuthToolkit', () => { now: () => 100, fetchImpl, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost, clientId: 'test-client-id', }, @@ -381,7 +381,7 @@ describe('PythinkerOAuthToolkit', () => { read: () => config, write, apply: (target, input) => { - target.providers[PYTHINKER_CODE_PROVIDER_NAME] = { + target.providers[KIMI_CODE_PROVIDER_NAME] = { type: 'pythinker', apiKey: '', }; @@ -394,7 +394,7 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, provision: { defaultModel: 'pythinker-code/pythinker-for-coding', @@ -417,11 +417,11 @@ describe('PythinkerOAuthToolkit', () => { it('uses a scoped credential slot for non-default OAuth login environments', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('prod-access')); - const config: ManagedPythinkerConfigShape = { providers: {} }; + storage.tokens.set('kimi-code', token('prod-access')); + const config: ManagedKimiConfigShape = { providers: {} }; const devBaseUrl = 'https://api.dev.example.test/coding/v1'; const devOauthHost = 'https://auth.dev.example.test'; - const devOauthKey = resolvePythinkerCodeOAuthKey({ + const devOauthKey = resolveKimiCodeOAuthKey({ oauthHost: devOauthHost, baseUrl: devBaseUrl, }); @@ -465,25 +465,25 @@ describe('PythinkerOAuthToolkit', () => { now: () => 100, fetchImpl: fetchMock as unknown as typeof fetch, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost: devOauthHost, clientId: 'test-client-id', }, configAdapter: { read: () => config, write, - apply: applyManagedPythinkerCodeConfig, + apply: applyManagedKimiCodeConfig, }, }); await expect(toolkit.login(undefined, { baseUrl: devBaseUrl })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(oauthFetch).toHaveBeenCalledTimes(2); - expect(storage.tokens.get('pythinker-code')?.accessToken).toBe('prod-access'); + expect(storage.tokens.get('kimi-code')?.accessToken).toBe('prod-access'); expect(storage.tokens.get(devStorageName)?.accessToken).toBe('dev-access'); - expect(config.providers[PYTHINKER_CODE_PROVIDER_NAME]?.oauth).toEqual({ + expect(config.providers[KIMI_CODE_PROVIDER_NAME]?.oauth).toEqual({ storage: 'file', key: devOauthKey, oauthHost: devOauthHost, @@ -496,7 +496,7 @@ describe('PythinkerOAuthToolkit', () => { it('starts a new device flow when the stored refresh token is invalid', async () => { const storage = new MemoryTokenStorage(); const oauthHost = 'https://auth.test'; - const oauthKey = resolvePythinkerCodeOAuthKey({ oauthHost }); + const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); const storageName = resolvePythinkerTokenStorageName({ oauthKey }); storage.tokens.set(storageName, { ...token('stale-access'), @@ -547,14 +547,14 @@ describe('PythinkerOAuthToolkit', () => { storage, now: () => 100, flowConfig: { - name: 'pythinker-code', + name: 'kimi-code', oauthHost, clientId: 'test-client-id', }, }); await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(onDeviceCode).toHaveBeenCalledTimes(1); @@ -563,8 +563,8 @@ describe('PythinkerOAuthToolkit', () => { it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); - storage.tokens.set('pythinker-code', token('access-1')); - const config = { providers: { [PYTHINKER_CODE_PROVIDER_NAME]: { type: 'pythinker' } } }; + storage.tokens.set('kimi-code', token('access-1')); + const config = { providers: { [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' } } }; const write = vi.fn(); const remove = vi.fn(); const toolkit = new PythinkerOAuthToolkit({ @@ -581,11 +581,11 @@ describe('PythinkerOAuthToolkit', () => { }); await expect(toolkit.logout()).resolves.toMatchObject({ - providerName: PYTHINKER_CODE_PROVIDER_NAME, + providerName: KIMI_CODE_PROVIDER_NAME, ok: true, }); expect(remove).toHaveBeenCalledWith(config); expect(write).toHaveBeenCalledWith(config); - await expect(storage.load('pythinker-code')).resolves.toBeUndefined(); + await expect(storage.load('kimi-code')).resolves.toBeUndefined(); }); }); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index c24b71df..258606f0 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -186,16 +186,16 @@ describe('GET /api/v1/auth — readiness probe (P2.1 D2)', () => { }); }); - it('surfaces managed_provider.unauthenticated when config has managed:pythinker-code but no cached token', async () => { + it('surfaces managed_provider.unauthenticated when config has managed:kimi-code but no cached token', async () => { seedConfig( [ - '[providers."managed:pythinker-code"]', + '[providers."managed:kimi-code"]', 'type = "pythinker"', 'base_url = "https://example/v1"', '', - '[providers."managed:pythinker-code".oauth]', + '[providers."managed:kimi-code".oauth]', 'storage = "file"', - 'key = "oauth/pythinker-code"', + 'key = "oauth/kimi-code"', '', ].join('\n'), ); @@ -204,7 +204,7 @@ describe('GET /api/v1/auth — readiness probe (P2.1 D2)', () => { const env = envelopeOf(res.json()); const summary = authSummarySchema.parse(env.data); expect(summary.managed_provider).toEqual({ - name: 'managed:pythinker-code', + name: 'managed:kimi-code', status: 'unauthenticated', }); // ready is still false — no default_model, even though provider exists diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 920aaefc..a4e9feb3 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -242,7 +242,7 @@ describe('model/provider catalog routes', () => { refreshOAuthProviderModels: async () => ({ changed: [ { - provider_id: 'managed:pythinker-code', + provider_id: 'managed:kimi-code', provider_name: 'Pythinker Code', added: 1, removed: 0, @@ -266,7 +266,7 @@ describe('model/provider catalog routes', () => { expect(env.data).toEqual({ changed: [ { - provider_id: 'managed:pythinker-code', + provider_id: 'managed:kimi-code', provider_name: 'Pythinker Code', added: 1, removed: 0, diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index 4437bc93..236f0346 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -91,7 +91,7 @@ function makeStub(scripted: { }; const defaultStart: OAuthFlowStart = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', user_code: 'PYTH-1234', @@ -116,7 +116,7 @@ function makeStub(scripted: { }, logout: async (provider) => { calls.logout.push({ provider }); - return scripted.logout ?? { logged_out: true, provider: 'managed:pythinker-code' }; + return scripted.logout ?? { logged_out: true, provider: 'managed:kimi-code' }; }, }; } @@ -221,7 +221,7 @@ describe('GET /api/v1/oauth/login (P2.7)', () => { it('returns the snapshot when present', async () => { const snap: OAuthFlowSnapshot = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', status: 'pending', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', @@ -247,7 +247,7 @@ describe('GET /api/v1/oauth/login (P2.7)', () => { it('reflects terminal-state snapshots', async () => { const snap: OAuthFlowSnapshot = { flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', status: 'authenticated', verification_uri: 'https://example.com/device', verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', @@ -312,7 +312,7 @@ describe('POST /api/v1/oauth/logout (P2.7)', () => { expect(env.code).toBe(0); expect(env.data).toEqual({ logged_out: true, - provider: 'managed:pythinker-code', + provider: 'managed:kimi-code', }); expect(stub.calls.logout).toHaveLength(1); }); From 79e7eeda4325c676aeba078f7271a72242840a82 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:32:26 -0400 Subject: [PATCH 13/36] fix(vscode): match slash commands on the command name only A description is prose, so matching it pulled in commands unrelated to what was typed: "/sk" reached "/yolo" because its description contains those letters, and the selection sat on it. Matching is now limited to the command name, which is what the user is typing and the only part they can predict. --- apps/vscode/test/slash-menu.test.ts | 17 +++++++++-------- .../inputarea/hooks/slash-command-match.ts | 19 +++++++------------ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/vscode/test/slash-menu.test.ts b/apps/vscode/test/slash-menu.test.ts index 7b67e52e..2c85445f 100644 --- a/apps/vscode/test/slash-menu.test.ts +++ b/apps/vscode/test/slash-menu.test.ts @@ -42,14 +42,15 @@ describe("scoreCommand", () => { expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "reswrit"))).toBe(true); }); - it("still falls back to the description when nothing matches the name", () => { - expect(Number.isFinite(scoreCommand(CUSTOM_THEME, "color"))).toBe(true); - }); - - it("ranks every name match above a description match", () => { - expect(rank([CUSTOM_THEME, command("theme-picker", "Nothing to see.")], "theme")).toEqual([ - "theme-picker", - "custom-theme", + it("never matches on the description", () => { + // "/sk" used to reach "/yolo" because its description contains those + // letters, so the highlight sat on an unrelated command. + const yolo = command("yolo", "Skip every approval prompt for this session."); + expect(scoreCommand(yolo, "sk")).toBe(NO_MATCH); + expect(rank([yolo, command("sub-skill"), command("invoke-skill")], "sk")).toEqual([ + "sub-skill", + "invoke-skill", ]); + expect(scoreCommand(CUSTOM_THEME, "color")).toBe(NO_MATCH); }); }); diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts index b802a04e..a0c2e360 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/slash-command-match.ts @@ -25,10 +25,12 @@ function matchesAnyWordPrefix(text: string, query: string): boolean { } /** - * Lower is a better match, `NO_MATCH` means the command is filtered out. Every - * way of matching the name outranks the description: the name is what the user - * is typing, and matching a long description loosely lets nearly every command - * through — which is what made the menu look unfiltered. + * Lower is a better match, `NO_MATCH` means the command is filtered out. + * + * Only the command name is matched. A description is prose — matching it pulls + * in commands that have nothing to do with what was typed (`/sk` reaching + * `/yolo` because its description happens to contain those letters), and the + * user cannot tell why they are listed. */ export function scoreCommand(command: SlashCommandInfo, query: string): number { const name = command.name.toLowerCase(); @@ -45,14 +47,7 @@ export function scoreCommand(command: SlashCommandInfo, query: string): number { return 2; } // Forgiving tier: skipped letters and dropped separators still match. - if (isSubsequence(letters(name), letters(q))) { - return 3; - } - const description = command.description; - if (matchesAnyWordPrefix(description, q)) { - return 4; - } - return description.toLowerCase().includes(q) ? 5 : NO_MATCH; + return isSubsequence(letters(name), letters(q)) ? 3 : NO_MATCH; } /** Commands that match `query`, best match first. An empty query keeps the original order. */ From 976acdb5976739f334807098ed757bb2cc4afed2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 13:41:50 -0400 Subject: [PATCH 14/36] fix(cli): strip the escape byte when tests compare rendered frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SGR pattern these tests used matched the `[0;…m` tail but not the ESC that introduces it, so every stripped frame kept a stray control byte between the styled spans. Assertions spanning two spans could then never match: a label and its value are coloured separately, so `Search: cwd` and `Kimi K2 Kimi ← current` had an invisible escape sitting in the middle. Ten assertions across six suites were failing on this. The pattern now includes the escape, and the two startup assertions that compared raw frames strip them like the rest. --- .../src/tui/components/chrome/welcome-banner.ts | 2 +- .../test/tui/components/dialogs/effort-selector.test.ts | 2 +- .../test/tui/components/dialogs/model-selector.test.ts | 4 ++-- .../test/tui/components/dialogs/provider-manager.test.ts | 2 +- .../tui/components/dialogs/tabbed-model-selector.test.ts | 2 +- .../test/tui/components/messages/goal-markers.test.ts | 2 +- .../tui/components/messages/tool-renderers/chip.test.ts | 2 +- .../tui/components/messages/tool-renderers/media.test.ts | 2 +- .../components/messages/tool-renderers/registry.test.ts | 2 +- .../test/tui/components/panels/footer-goal-badge.test.ts | 2 +- .../controllers/session-event-handler-goal-queue.test.ts | 2 +- .../test/tui/pythinker-tui-message-flow.test.ts | 4 ++-- .../pythinker-code/test/tui/pythinker-tui-startup.test.ts | 8 ++++++-- apps/pythinker-code/test/tui/task-output-viewer.test.ts | 2 +- apps/pythinker-code/test/tui/tasks-browser.test.ts | 2 +- 15 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts index 59dc6902..949ed93a 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts @@ -225,7 +225,7 @@ function renderTipsBlock( if (withRule) { const ruleChar = asciiMode ? '-' : '─'; - lines.push(muted(ruleChar.repeat(Math.max(4, width)))); + lines.push(muted(ruleChar.repeat(tipWidth))); } for (const item of tips) { diff --git a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts index b02966e5..05dd4400 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI = /\[[0-9;]*m/g; +const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); const DOWN = `${ESC}[B`; diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index d327df94..2e05b222 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -6,7 +6,7 @@ import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector' import { parseKeybindingBlocks } from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; -const ANSI = /\[[0-9;]*m/g; +const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); const UP = `${ESC}[A`; @@ -95,7 +95,7 @@ describe('ModelSelectorComponent', () => { const out = text(picker); // Model name on the left, provider on the right, with the current marker. - expect(out).toMatch(/❯ Kimi K2\s+Pythinker ← current/); + expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/); expect(out).not.toContain('Kimi K2 (Kimi)'); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts index 93a300ff..092f3b7d 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts @@ -101,7 +101,7 @@ describe('ProviderManagerComponent', () => { const plain = component .render(120) .join('\n') - .replaceAll(/\[[0-9;]*m/g, ''); + .replaceAll(/\u001B\[[0-9;]*m/g, ''); expect(plain).toContain('← current'); expect(plain).not.toContain('●'); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index ad82708f..09969d91 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -75,7 +75,7 @@ describe('TabbedModelSelectorComponent', () => { expect(component.activeTabId()).toBe('managed:kimi-code'); expect(out).toContain('Kimi K2'); expect(out).not.toContain('GPT-5'); - expect(out).toMatch(/❯ Kimi K2\s+Pythinker ← current/u); + expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/u); }); it('opens the matching provider when the current canonical alias is stale', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts index f79a9bc2..98d4f3fd 100644 --- a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts @@ -5,7 +5,7 @@ import { DynamicWorkflowModeMarkerComponent } from '#/tui/components/messages/dy import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; import type { GoalChange } from '@pythoughts/pythinker-code-sdk'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(lines: string[]): string { return lines.join('\n').replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts index 4a0b69d4..2134d356 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -8,7 +8,7 @@ import { import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function call(name: string, args: Record = {}): ToolCallBlockData { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd..8a13aeca 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -10,7 +10,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts index 4636f68d..70c13d35 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -9,7 +9,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, ''); + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { diff --git a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts index 4824636a..81bbb551 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -5,7 +5,7 @@ import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import type { GoalSnapshot } from '@pythoughts/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 325b2b34..952a982e 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -232,7 +232,7 @@ function modelBlockedEvent() { function addedTranscriptText(host: ReturnType['host']): string { const component = host.state.transcriptContainer.addChild.mock.calls.at(-1)?.[0]; - return component.render(80).join('\n').replaceAll(/\[[0-9;]*m/g, ''); + return component.render(80).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); } function renderContainer(container: Container): string { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 5e19a923..3c219d92 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -5223,8 +5223,8 @@ command = "vim" }); const picker = driver.state.editorContainer.children[0]; const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toMatch(/Kimi K2\s+Pythinker ← current/); - expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Pythinker/); + expect(pickerOutput).toMatch(/Kimi K2\s+Kimi ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 2f98f0da..b710340b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -42,6 +42,10 @@ import { } from '#/tui/utils/terminal-theme'; import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; +/** The picker colours labels and values separately, so raw frames interleave SGR escapes. */ +const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const stripAnsi = (frame: string): string => frame.replaceAll(ANSI_SGR, ''); + vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -1178,7 +1182,7 @@ describe('PythinkerTUI startup', () => { firstPicker.handleInput('c'); firstPicker.handleInput('w'); firstPicker.handleInput('d'); - expect(firstPicker.render(160).join('\n')).toContain('Search: cwd'); + expect(stripAnsi(firstPicker.render(160).join('\n'))).toContain('Search: cwd'); firstPicker.handleInput('\u0001'); await new Promise((resolve) => setImmediate(resolve)); @@ -1187,7 +1191,7 @@ describe('PythinkerTUI startup', () => { handleInput(data: string): void; render(width: number): string[]; }; - const output = allPicker.render(160).join('\n'); + const output = stripAnsi(allPicker.render(160).join('\n')); expect(driver.state.sessionsScope).toBe('all'); expect(output).toContain('All sessions'); diff --git a/apps/pythinker-code/test/tui/task-output-viewer.test.ts b/apps/pythinker-code/test/tui/task-output-viewer.test.ts index 1a07c9d4..d95c275f 100644 --- a/apps/pythinker-code/test/tui/task-output-viewer.test.ts +++ b/apps/pythinker-code/test/tui/task-output-viewer.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; import { darkColors } from '@/tui/theme/colors'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/tasks-browser.test.ts b/apps/pythinker-code/test/tui/tasks-browser.test.ts index 86f1af9c..01d07545 100644 --- a/apps/pythinker-code/test/tui/tasks-browser.test.ts +++ b/apps/pythinker-code/test/tui/tasks-browser.test.ts @@ -10,7 +10,7 @@ import { import { darkColors } from '@/tui/theme/colors'; import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI_SGR = /\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } From 40419aa2ce0b363b2677880bcb3a96a4ee3e6332 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:34:10 -0400 Subject: [PATCH 15/36] fix(vscode): show the logo as the first step marker of an assistant message The logo only rendered inside the streaming thinking indicator, so a finished message showed a bare step dot and nothing identified the assistant. --- .../webview-ui/src/components/ChatMessage.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index 80429899..64fb9612 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -82,7 +82,7 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { } } -function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) { +function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConnector?: boolean; showLogo?: boolean }) { const hasItems = step.items.length > 0; const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); const showIndicator = hasToolOrThinking; @@ -96,16 +96,26 @@ function StepContent({ step, showConnector }: { step: UIStep; showConnector?: bo
{showIndicator ? (
-
+ {showLogo ? ( + + ) : ( +
+ )} {showConnector && (
)}
@@ -295,6 +305,8 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe {hasSteps && groupStepsByPlanMode(steps).map((group, gi) => { const totalSteps = steps.length; + // The logo identifies the assistant once per message; later steps keep the dot. + const firstIndicatorIndex = stepHasIndicator.indexOf(true); const stepsContent = group.steps.map((step, i) => { const globalIndex = group.startIndex + i; const isLastInGroup = i === group.steps.length - 1; @@ -302,7 +314,7 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe const hasIndicator = stepHasIndicator[globalIndex]; const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; - return ; + return ; }); if (group.planMode) { From d4fe97dbfa85654ad9ff0cd74dc40e5c09fdc117 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:36:35 -0400 Subject: [PATCH 16/36] fix(vscode): align the assistant logo with the reply instead of the step A step opens with its thinking or tool block, so a marker in the step gutter sat beside that block rather than beside the text the logo is meant to label. --- .../webview-ui/src/components/ChatMessage.tsx | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index 64fb9612..a07cc611 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -86,6 +86,7 @@ function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConn const hasItems = step.items.length > 0; const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); const showIndicator = hasToolOrThinking; + const logoItemIndex = step.items.findIndex((item) => item.type === "text"); const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished); if (!hasItems) { @@ -96,26 +97,16 @@ function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConn
{showIndicator ? (
- {showLogo ? ( - - ) : ( -
- )} +
{showConnector && (
)}
@@ -123,9 +114,23 @@ function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConn
)}
- {step.items.map((item, idx) => ( - - ))} + {step.items.map((item, idx) => { + const renderer = ; + if (!showLogo || idx !== logoItemIndex) { + return {renderer}; + } + // The logo belongs beside the reply itself, not beside whatever tool or + // thinking block happens to open the step, so it claims the outer gutter + // from this row: `-ml-7` is the `w-5` marker column plus the `gap-2`. + return ( +
+
+ +
+
{renderer}
+
+ ); + })}
); @@ -305,8 +310,8 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe {hasSteps && groupStepsByPlanMode(steps).map((group, gi) => { const totalSteps = steps.length; - // The logo identifies the assistant once per message; later steps keep the dot. - const firstIndicatorIndex = stepHasIndicator.indexOf(true); + // The logo identifies the assistant once per message, on its first reply. + const firstTextStepIndex = steps.findIndex((s) => s.items.some((item) => item.type === "text")); const stepsContent = group.steps.map((step, i) => { const globalIndex = group.startIndex + i; const isLastInGroup = i === group.steps.length - 1; @@ -314,7 +319,7 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe const hasIndicator = stepHasIndicator[globalIndex]; const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; - return ; + return ; }); if (group.planMode) { From c298e1323653761a756778c09000f4d093ae50cc Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:40:16 -0400 Subject: [PATCH 17/36] fix(vscode): show the assistant logo on replies that have no steps The step-less branch rendered its markdown behind a bare left padding, so a plain text reply carried no logo. Both branches now share one logo gutter row. --- .../webview-ui/src/components/ChatMessage.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index a07cc611..be2a518d 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { useState, Fragment, memo } from "react"; +import { useState, Fragment, memo, type ReactNode } from "react"; import { IconGitFork, IconBolt } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Content } from "@/lib/content"; @@ -82,7 +82,19 @@ function StepItemRenderer({ item }: { item: UIStepItem }) { } } -function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConnector?: boolean; showLogo?: boolean }) { +/** A reply row whose gutter carries the assistant logo, lined up with the step markers. */ +function LogoRow({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+
+ +
+
{children}
+
+ ); +} + +function StepContent({ step, showConnector, showLogo }:{ step: UIStep; showConnector?: boolean; showLogo?: boolean }) { const hasItems = step.items.length > 0; const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); const showIndicator = hasToolOrThinking; @@ -123,12 +135,9 @@ function StepContent({ step, showConnector, showLogo }: { step: UIStep; showConn // thinking block happens to open the step, so it claims the outer gutter // from this row: `-ml-7` is the `w-5` marker column plus the `gap-2`. return ( -
-
- -
-
{renderer}
-
+ + {renderer} + ); })}
@@ -327,7 +336,11 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe } return {stepsContent}; })} - {!hasSteps && displayContent && } + {!hasSteps && displayContent && ( + + + + )} {(images.length > 0 || videos.length > 0) && (
From 3b844256da4411774236b830df215a54b6c69c7b Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:54:17 -0400 Subject: [PATCH 18/36] fix(vscode): complete the slash token instead of sending on selection Picking a command sent it on its own and cleared the input, so a command written into a longer message discarded that message. It now fills the token in place, like a file mention, and the user keeps typing. --- .../src/components/inputarea/InputArea.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index f2c65660..cf2e2f9a 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -182,9 +182,23 @@ export function InputArea({ onAuthAction }: InputAreaProps) { clearInput(); }); + const applyText = useMemoizedFn((newText: string, newCursorPos: number) => { + setText(newText); + setCursorPos(newCursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }); + const handleSlashCommand = useMemoizedFn((name: string) => { - sendMessage(`/${name}`); - clearInput(); + // Picking a command completes the token being typed. Sending it here would + // discard the rest of the message the command was being written into. + const before = activeToken ? text.slice(0, activeToken.start) : text; + const after = activeToken ? text.slice(cursorPos) : ""; + const insertion = `/${name} `; + applyText(`${before}${insertion}${after}`, before.length + insertion.length); }); const applyMention = useMemoizedFn((filePath: string) => { @@ -196,13 +210,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { isAppend: false, }); - setText(newText); - setCursorPos(newCursorPos); - setTimeout(() => { - textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); - textareaRef.current?.focus(); - adjustHeight(); - }, 0); + applyText(newText, newCursorPos); }); const { From 932d79887c1783ce3adac83407509d6dd403ecfe Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:55:05 -0400 Subject: [PATCH 19/36] feat(tui): animate welcome logo with blink loop and startup antenna spin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blink repeats every 5s instead of playing once (WELCOME_BLINK_INTERVAL_MS) - Antenna bulb spins through ◐◓◑◒ frames for 6s at banner load, then restores the static ● (WELCOME_ANTENNA_SPIN_* constants) - Logo renderer gains renderPythinkerLogoAntennaRow + antennaFrame param - Animator renamed WelcomeLogoEyeAnimator → WelcomeLogoAnimator with per-loop timers (blink + spin) and a setAntennaFrame host hook --- .../tui/components/chrome/pythinker-logo.ts | 34 ++++++++- .../chrome/welcome-logo-animation.ts | 74 ++++++++++++++++--- .../src/tui/components/chrome/welcome.ts | 17 +++-- .../components/chrome/pythinker-logo.test.ts | 13 ++++ .../chrome/welcome-eye-animation.test.ts | 63 +++++++++++++++- 5 files changed, 178 insertions(+), 23 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts b/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts index 5ede63eb..ef036139 100644 --- a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts +++ b/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts @@ -43,6 +43,13 @@ export const LOGO_EYE_ROW = 3; export const LOGO_LEFT_EYE_COL = 4; export const LOGO_RIGHT_EYE_COL = 8; +/** Antenna bulb cell on row 0. */ +export const LOGO_ANTENNA_ROW = 0; +export const LOGO_ANTENNA_COL = 6; + +/** Antenna spin frames — half-shaded circles read as a clockwise rotation. */ +export const ANTENNA_SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] as const; + /** Eye phases from the installer `_blink_eyes` / `Blink-Eye` sequence. */ export type EyeBlinkPhase = 'open' | 'glance' | 'closed' | 'open-shine'; @@ -157,10 +164,29 @@ export function renderPythinkerLogoEyeRow(state: LogoEyeBlinkState): string { return segment(line, ranges); } -export function renderPythinkerLogoWithEyes(state: LogoEyeBlinkState = LOGO_EYES_OPEN): string[] { - return PYTHINKER_LOGO_LINES.map((_, index) => - index === LOGO_EYE_ROW ? renderPythinkerLogoEyeRow(state) : renderPythinkerLogoLine(index), - ); +/** Paint the antenna row with a spinner frame in place of the static bulb. */ +export function renderPythinkerLogoAntennaRow(frameIndex: number): string { + const frame = + ANTENNA_SPINNER_FRAMES[frameIndex % ANTENNA_SPINNER_FRAMES.length] ?? '●'; + const chars = Array.from(PYTHINKER_LOGO_LINES[LOGO_ANTENNA_ROW]); + chars[LOGO_ANTENNA_COL] = frame; + const line = chars.join(''); + return segment(line, [ + [LOGO_ANTENNA_COL, LOGO_ANTENNA_COL + frame.length, antenna], + ]); +} + +export function renderPythinkerLogoWithEyes( + state: LogoEyeBlinkState = LOGO_EYES_OPEN, + antennaFrame?: number, +): string[] { + return PYTHINKER_LOGO_LINES.map((_, index) => { + if (index === LOGO_EYE_ROW) return renderPythinkerLogoEyeRow(state); + if (index === LOGO_ANTENNA_ROW && antennaFrame !== undefined) { + return renderPythinkerLogoAntennaRow(antennaFrame); + } + return renderPythinkerLogoLine(index); + }); } /** Paint one logo row with SVG-accurate brand colors. */ diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts index ac442b2f..5e473e11 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts @@ -1,10 +1,13 @@ /** - * Welcome-banner eye blink — same phases as install.ps1 `Blink-Eye` / install.sh - * `_blink_eyes`, with longer holds so the closed eye registers in the TUI. + * Welcome-banner animation — eye blink with the same phases as install.ps1 + * `Blink-Eye` / install.sh `_blink_eyes` (longer holds so the closed eye + * registers in the TUI), plus a one-shot antenna spin while the banner + * first loads. */ import { asciiGlyphsEnabled } from './welcome-banner'; import { + ANTENNA_SPINNER_FRAMES, LOGO_EYES_OPEN, type EyeBlinkPhase, type LogoEyeBlinkState, @@ -33,17 +36,28 @@ export function welcomeLogoAnimationEnabled(): boolean { return true; } -export interface WelcomeEyeAnimationHost { +export interface WelcomeLogoAnimationHost { setEyeBlinkState(state: LogoEyeBlinkState): void; + /** `null` restores the static ● bulb. */ + setAntennaFrame(frame: number | null): void; } -export class WelcomeLogoEyeAnimator { +/** Idle time between blink sequences. */ +export const WELCOME_BLINK_INTERVAL_MS = 5000; + +/** Antenna spin cadence and total length — one spin at banner load. */ +export const WELCOME_ANTENNA_SPIN_TICK_MS = 120; +export const WELCOME_ANTENNA_SPIN_DURATION_MS = 6000; + +export class WelcomeLogoAnimator { private eyeState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private timer: ReturnType | null = null; + private antennaFrameIndex = 0; + private blinkTimer: ReturnType | null = null; + private spinTimer: ReturnType | null = null; private disposed = false; constructor( - private readonly host: WelcomeEyeAnimationHost, + private readonly host: WelcomeLogoAnimationHost, private readonly requestRender: () => void, ) {} @@ -53,20 +67,58 @@ export class WelcomeLogoEyeAnimator { start(): void { if (!welcomeLogoAnimationEnabled() || this.disposed) return; + this.spinAntenna(0); + this.playBlink(); + } + + private playBlink(): void { this.runEye('left', 0, () => { this.runEye('right', 0, () => { this.applyState(LOGO_EYES_OPEN); + this.scheduleNextBlink(); }); }); } + private scheduleNextBlink(): void { + if (this.disposed) return; + this.blinkTimer = setTimeout(() => { + this.blinkTimer = null; + this.playBlink(); + }, WELCOME_BLINK_INTERVAL_MS); + } + + private spinAntenna(elapsedMs: number): void { + if (this.disposed) return; + if (elapsedMs >= WELCOME_ANTENNA_SPIN_DURATION_MS) { + this.applyAntennaFrame(null); + return; + } + this.applyAntennaFrame(this.antennaFrameIndex); + this.antennaFrameIndex = (this.antennaFrameIndex + 1) % ANTENNA_SPINNER_FRAMES.length; + this.spinTimer = setTimeout(() => { + this.spinTimer = null; + this.spinAntenna(elapsedMs + WELCOME_ANTENNA_SPIN_TICK_MS); + }, WELCOME_ANTENNA_SPIN_TICK_MS); + } + + private applyAntennaFrame(frame: number | null): void { + this.host.setAntennaFrame(frame); + this.requestRender(); + } + dispose(): void { this.disposed = true; - if (this.timer !== null) { - clearTimeout(this.timer); - this.timer = null; + if (this.blinkTimer !== null) { + clearTimeout(this.blinkTimer); + this.blinkTimer = null; + } + if (this.spinTimer !== null) { + clearTimeout(this.spinTimer); + this.spinTimer = null; } this.applyState(LOGO_EYES_OPEN); + this.applyAntennaFrame(null); } private runEye(side: 'left' | 'right', stepIndex: number, done: () => void): void { @@ -84,8 +136,8 @@ export class WelcomeLogoEyeAnimator { this.runEye(side, stepIndex + 1, done); return; } - this.timer = setTimeout(() => { - this.timer = null; + this.blinkTimer = setTimeout(() => { + this.blinkTimer = null; this.runEye(side, stepIndex + 1, done); }, step.delayMs); } diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index 998f8cd6..9d145d38 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -19,9 +19,9 @@ import { type LogoEyeBlinkState, } from './pythinker-logo'; import { - WelcomeLogoEyeAnimator, + WelcomeLogoAnimator, welcomeLogoAnimationEnabled, - type WelcomeEyeAnimationHost, + type WelcomeLogoAnimationHost, } from './welcome-logo-animation'; import { asciiGlyphsEnabled, @@ -32,11 +32,12 @@ import { renderWelcomeBanner, } from './welcome-banner'; -export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { +export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { private state: AppState; private readonly gitCache: GitStatusCache; private eyeBlinkState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private eyeAnimator: WelcomeLogoEyeAnimator | null = null; + private antennaFrame: number | null = null; + private eyeAnimator: WelcomeLogoAnimator | null = null; constructor( state: AppState, @@ -45,7 +46,7 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { this.state = state; this.gitCache = createWelcomeGitCache(state.workDir); if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowColorActive()) { - this.eyeAnimator = new WelcomeLogoEyeAnimator(this, requestRender); + this.eyeAnimator = new WelcomeLogoAnimator(this, requestRender); queueMicrotask(() => this.eyeAnimator?.start()); } } @@ -54,6 +55,10 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { this.eyeBlinkState = state; } + setAntennaFrame(frame: number | null): void { + this.antennaFrame = frame; + } + invalidate(): void {} dispose(): void { @@ -69,7 +74,7 @@ export class WelcomeComponent implements Component, WelcomeEyeAnimationHost { const logoLines = isRainbowColorActive() ? renderRainbowWelcomeLogo() - : renderPythinkerLogoWithEyes(this.eyeBlinkState); + : renderPythinkerLogoWithEyes(this.eyeBlinkState, this.antennaFrame ?? undefined); return renderWelcomeBanner({ width, diff --git a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts b/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts index 1aa03eca..897a287c 100644 --- a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts @@ -3,11 +3,13 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + ANTENNA_SPINNER_FRAMES, buildLogoHeaderRows, LOGO_EYES_OPEN, PYTHINKER_LOGO_LINES, PYTHINKER_LOGO_WIDTH, renderPythinkerLogo, + renderPythinkerLogoAntennaRow, renderPythinkerLogoEyeRow, renderPythinkerLogoLine, renderPythinkerLogoWithEyes, @@ -68,6 +70,17 @@ describe('pythinker logo', () => { expect(visibleWidth(glance)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[3])); }); + it('renders antenna spinner frames at stable width', () => { + for (let frame = 0; frame < ANTENNA_SPINNER_FRAMES.length; frame++) { + const row = renderPythinkerLogoAntennaRow(frame); + expect(row).toContain(ANTENNA_SPINNER_FRAMES[frame]); + expect(visibleWidth(row)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[0])); + } + const lines = renderPythinkerLogoWithEyes(LOGO_EYES_OPEN, 2); + expect(lines).toHaveLength(5); + expect(lines[0]).toContain(ANTENNA_SPINNER_FRAMES[2]); + }); + it('keeps logo width stable across eye blink states', () => { const lines = renderPythinkerLogoWithEyes({ left: 'open-shine', right: 'closed' }); expect(lines).toHaveLength(5); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts index 7e7113bb..110de8e1 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts @@ -1,7 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { WelcomeLogoEyeAnimator, welcomeLogoAnimationEnabled } from '#/tui/components/chrome/welcome-logo-animation'; +import { + WELCOME_ANTENNA_SPIN_DURATION_MS, + WELCOME_ANTENNA_SPIN_TICK_MS, + WELCOME_BLINK_INTERVAL_MS, + WelcomeLogoAnimator, + welcomeLogoAnimationEnabled, +} from '#/tui/components/chrome/welcome-logo-animation'; import { LOGO_EYES_OPEN } from '#/tui/components/chrome/pythinker-logo'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { setRainbowColors } from '#/tui/easter-eggs/rainbow-colors'; @@ -66,8 +72,9 @@ describe('WelcomeComponent eye animation', () => { setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { states.push(`${state.left}:${state.right}`); }, + setAntennaFrame(_frame: number | null) {}, }; - const animator = new WelcomeLogoEyeAnimator(host, () => {}); + const animator = new WelcomeLogoAnimator(host, () => {}); animator.start(); expect(states[0]).toBe('glance:open'); @@ -79,6 +86,58 @@ describe('WelcomeComponent eye animation', () => { animator.dispose(); }); + it('repeats the blink every 5 seconds', () => { + const states: string[] = []; + const host = { + setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { + states.push(`${state.left}:${state.right}`); + }, + setAntennaFrame(_frame: number | null) {}, + }; + const animator = new WelcomeLogoAnimator(host, () => {}); + animator.start(); + + const glanceCount = () => states.filter((s) => s === 'glance:open').length; + vi.advanceTimersByTime(1140); // one full two-eye blink sequence + expect(states.at(-1)).toBe('open:open'); + expect(glanceCount()).toBe(1); + + vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS); + expect(glanceCount()).toBe(2); + + vi.advanceTimersByTime(1140 + WELCOME_BLINK_INTERVAL_MS); + expect(glanceCount()).toBe(3); + animator.dispose(); + + const afterDispose = glanceCount(); + vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS + 2000); + expect(glanceCount()).toBe(afterDispose); + }); + + it('spins the antenna for six seconds on startup, then restores the bulb', () => { + const frames: (number | null)[] = []; + const host = { + setEyeBlinkState(_state: typeof LOGO_EYES_OPEN) {}, + setAntennaFrame(frame: number | null) { + frames.push(frame); + }, + }; + const animator = new WelcomeLogoAnimator(host, () => {}); + animator.start(); + + expect(frames[0]).toBe(0); + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_TICK_MS * 3); + expect(frames).toEqual([0, 1, 2, 3]); + + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); + expect(frames.at(-1)).toBeNull(); + + const settled = frames.length; + vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); + expect(frames.length).toBe(settled); + animator.dispose(); + }); + it('plays the installer blink sequence on startup', async () => { let renderCount = 0; let lastOutput = ''; From f6beeb7d16d3e9d39020db1bf3f03e05c0a9c59a Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:56:40 -0400 Subject: [PATCH 20/36] chore(vscode): refresh the webview logo assets Shrinks pythinker-logo.png from 264 KB to 59 KB and adds the animated logo variant beside it. --- .../webview-ui/public/pythinker-logo.png | Bin 270376 -> 60392 bytes .../webview-ui/public/pythinker_animated.svg | 79 ++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 apps/vscode/webview-ui/public/pythinker_animated.svg diff --git a/apps/vscode/webview-ui/public/pythinker-logo.png b/apps/vscode/webview-ui/public/pythinker-logo.png index 865d75291be427c3820b8075e6215a304842f8c4..4fdf646bf8f066bb0d2bec6e28441c5a25f15799 100644 GIT binary patch literal 60392 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 AMgRZ+ literal 270376 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 diff --git a/apps/vscode/webview-ui/public/pythinker_animated.svg b/apps/vscode/webview-ui/public/pythinker_animated.svg new file mode 100644 index 00000000..bf23b5bc --- /dev/null +++ b/apps/vscode/webview-ui/public/pythinker_animated.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 67651e5aba6edc414a1888a0617db86eb6ca4f40 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 14:58:36 -0400 Subject: [PATCH 21/36] fix(vscode): keep the step timeline unbroken by the assistant logo A multi-step reply draws a connector between its step markers. The logo sits in that same gutter, so it cut the line in half. Multi-step replies now keep the timeline and drop the logo; single-step replies still show it. --- apps/vscode/webview-ui/src/components/ChatMessage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx index be2a518d..00373f8f 100644 --- a/apps/vscode/webview-ui/src/components/ChatMessage.tsx +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -319,8 +319,11 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe {hasSteps && groupStepsByPlanMode(steps).map((group, gi) => { const totalSteps = steps.length; - // The logo identifies the assistant once per message, on its first reply. - const firstTextStepIndex = steps.findIndex((s) => s.items.some((item) => item.type === "text")); + // The logo identifies the assistant on its first reply, but only when the + // message draws no timeline: sitting in the gutter, it would cut the + // connector running between the step markers. + const hasTimeline = stepHasIndicator.filter(Boolean).length > 1; + const firstTextStepIndex = hasTimeline ? -1 : steps.findIndex((s) => s.items.some((item) => item.type === "text")); const stepsContent = group.steps.map((step, i) => { const globalIndex = group.startIndex + i; const isLastInGroup = i === group.steps.length - 1; From e9a1d20f2567a3cb7763501f8ab6e0822e4988a4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:06:07 -0400 Subject: [PATCH 22/36] chore: add a changeset for the vscode chat and slash command fixes --- .changeset/vscode-chat-logo-and-slash-insert.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/vscode-chat-logo-and-slash-insert.md diff --git a/.changeset/vscode-chat-logo-and-slash-insert.md b/.changeset/vscode-chat-logo-and-slash-insert.md new file mode 100644 index 00000000..4d885681 --- /dev/null +++ b/.changeset/vscode-chat-logo-and-slash-insert.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": patch +--- + +Show the assistant logo beside replies without breaking the step timeline, and complete a picked slash command in the input instead of sending it on its own. From 5902c2bdd61a79334e7747baf6a0fb36280b7672 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:11:24 -0400 Subject: [PATCH 23/36] chore(vscode): align the extension version with the CLI release line The extension shipped from its own 0.6.x line while the CLI was on 0.8.x, so the two versions could not be matched up when reporting an issue. --- apps/vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 49adb4ca..90f184f3 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -3,7 +3,7 @@ "publisher": "pythoughts", "displayName": "Pythinker Code", "description": "Pythinker Code extension for VS Code", - "version": "0.6.7", + "version": "0.8.2", "private": true, "license": "Apache-2.0", "type": "module", From 28335abc4c47e7e3f37303cfb9feb74a7276f8bd Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:22:06 -0400 Subject: [PATCH 24/36] feat(vscode): allow a manual publish to authenticate with Entra ID VSCE_AZURE_CREDENTIAL=1 passes vsce's --azure-credential, so a maintainer can publish as the identity az login already established instead of minting a PAT. CI keeps using VSCE_PAT. --- apps/vscode/scripts/vsix-publish.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/vscode/scripts/vsix-publish.mjs b/apps/vscode/scripts/vsix-publish.mjs index e7952478..4949f293 100644 --- a/apps/vscode/scripts/vsix-publish.mjs +++ b/apps/vscode/scripts/vsix-publish.mjs @@ -12,7 +12,12 @@ async function main() { console.log(publishUsage('Visual Studio Marketplace')); return; } - if (!process.env.VSCE_PAT) throw new Error('VSCE_PAT is required to publish.'); + // A token is the only option in CI, but a maintainer publishing by hand can + // authenticate as the Entra identity `az login` already established instead. + const azureCredential = process.env.VSCE_AZURE_CREDENTIAL === '1'; + if (!azureCredential && !process.env.VSCE_PAT) { + throw new Error('Set VSCE_PAT, or VSCE_AZURE_CREDENTIAL=1 to publish as the signed-in Entra identity.'); + } await verifyInputs(options); for (const file of options.files) { @@ -20,7 +25,7 @@ async function main() { runLocalCli( '@vscode/vsce', 'vsce', - ['publish', '--packagePath', file, '--skip-duplicate'], + ['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])], { cwd: extensionRoot }, ); } From 04ae0d576660aebc7fdbf6e1eb292219861ad0e3 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:30:06 -0400 Subject: [PATCH 25/36] feat(vscode): add a one-command tag-and-publish release script Bumping, building, packaging six targets, publishing and tagging were separate manual steps, and the Marketplace token had to be pasted in by hand each time. The script runs them in order behind preflight checks and reads the token from the keychain. --- apps/vscode/package.json | 1 + apps/vscode/scripts/release-extension.mjs | 155 ++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 apps/vscode/scripts/release-extension.mjs diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 90f184f3..18258f6f 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -249,6 +249,7 @@ "package:platform": "node scripts/vsix-package.mjs", "package:verify": "node scripts/vsix-verify.mjs", "publish:vsix": "node scripts/vsix-publish.mjs", + "release": "node scripts/release-extension.mjs", "publish:ovsx": "node scripts/ovsx-publish.mjs" }, "devDependencies": { diff --git a/apps/vscode/scripts/release-extension.mjs b/apps/vscode/scripts/release-extension.mjs new file mode 100644 index 00000000..02053893 --- /dev/null +++ b/apps/vscode/scripts/release-extension.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// Tag and publish the VS Code extension in one pass: +// preflight -> bump -> commit -> build -> package+verify -> publish -> tag -> push. +// +// pnpm --filter pythinker-code run release 0.8.3 +// pnpm --filter pythinker-code run release 0.8.3 --dry-run +// +// The Marketplace token is read from the macOS keychain, so it never reaches a +// shell history or a file. Store it once with: +// security add-generic-password -s pythinker-vsce-pat -a "$USER" -w +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { extensionRoot, isMainModule } from './vsix-targets.mjs'; + +const KEYCHAIN_SERVICE = 'pythinker-vsce-pat'; +const OVSX_KEYCHAIN_SERVICE = 'pythinker-ovsx-pat'; +const MANIFEST = join(extensionRoot, 'package.json'); +const repoRoot = join(extensionRoot, '..', '..'); + +function run(command, args, options = {}) { + return execFileSync(command, args, { stdio: 'inherit', cwd: repoRoot, ...options }); +} + +function capture(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', cwd: repoRoot, ...options }).trim(); +} + +/** A secret in argv or the environment leaks into `ps` and shell history; the keychain does not. */ +function keychainSecret(service) { + try { + return capture('security', ['find-generic-password', '-s', service, '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + return ''; + } +} + +function parseArguments(argv) { + let version = ''; + let dryRun = false; + for (const argument of argv) { + if (argument === '--dry-run') dryRun = true; + else if (argument === '--') continue; + else if (argument.startsWith('-')) throw new Error(`Unknown option: ${argument}`); + else if (version) throw new Error('Pass exactly one version.'); + else version = argument; + } + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error('Usage: release [--dry-run]'); + } + return { version, dryRun }; +} + +function readManifest() { + return JSON.parse(readFileSync(MANIFEST, 'utf8')); +} + +function isNewer(next, current) { + const a = next.split('.').map(Number); + const b = current.split('.').map(Number); + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] > b[i]; + } + return false; +} + +/** The Marketplace refuses a duplicate version, so catch it before anything is built. */ +async function assertUnpublished(version) { + const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json;api-version=7.1-preview.1' }, + body: JSON.stringify({ filters: [{ criteria: [{ filterType: 7, value: 'pythoughts.pythinker-code' }] }], flags: 914 }), + }); + if (!response.ok) { + console.warn(`Could not reach the Marketplace to check for ${version}; continuing.`); + return; + } + const body = await response.json(); + const versions = body.results?.[0]?.extensions?.[0]?.versions ?? []; + if (versions.some((entry) => entry.version === version)) { + throw new Error(`${version} is already on the Marketplace. Versions cannot be reused — pick the next one.`); + } +} + +function assertCleanTree() { + if (capture('git', ['status', '--porcelain'])) { + throw new Error('Working tree is dirty. Commit or stash first so the tag matches what ships.'); + } +} + +async function main() { + const { version, dryRun } = parseArguments(process.argv.slice(2)); + const tag = `pythinker-code-vscode@${version}`; + const manifest = readManifest(); + + assertCleanTree(); + if (!isNewer(version, manifest.version)) { + throw new Error(`${version} does not come after the current ${manifest.version}.`); + } + if (capture('git', ['tag', '--list', tag])) { + throw new Error(`Tag ${tag} already exists.`); + } + await assertUnpublished(version); + + const vscePat = process.env.VSCE_PAT || keychainSecret(KEYCHAIN_SERVICE); + if (!vscePat && !dryRun) { + throw new Error( + `No Marketplace token. Store one with: security add-generic-password -s ${KEYCHAIN_SERVICE} -a "$USER" -w`, + ); + } + + console.log(`\n== ${manifest.version} -> ${version}${dryRun ? ' (dry run)' : ''} ==\n`); + writeFileSync(MANIFEST, readFileSync(MANIFEST, 'utf8').replace(`"version": "${manifest.version}"`, `"version": "${version}"`)); + + run('pnpm', ['build']); + run('pnpm', ['--filter', 'pythinker-code', 'run', 'package:platform']); + + if (dryRun) { + console.log(`\nDry run: built and verified ${version}. Nothing published, nothing tagged.`); + console.log('Restore the manifest with: git checkout apps/vscode/package.json'); + return; + } + + // Commit before publishing so the shipped bits always correspond to a commit, + // and tag only once the Marketplace has actually accepted them. + run('git', ['add', 'apps/vscode/package.json']); + run('git', ['commit', '-m', `chore(vscode): release ${version}`]); + + run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } }); + + const ovsxPat = process.env.OVSX_PAT || keychainSecret(OVSX_KEYCHAIN_SERVICE); + if (ovsxPat) { + // Open VSX serves Cursor / VSCodium / Windsurf, but it must never undo a + // successful Marketplace publish. + try { + run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:ovsx'], { env: { ...process.env, OVSX_PAT: ovsxPat } }); + } catch (error) { + console.warn(`Open VSX publish failed, Marketplace is live: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + console.warn(`No Open VSX token (keychain service ${OVSX_KEYCHAIN_SERVICE}) — skipped.`); + } + + run('git', ['tag', '-a', tag, '-m', `Pythinker Code VS Code extension ${version}`]); + console.log(`\nPublished ${version} and tagged ${tag}.`); + console.log(`Push it with: git push origin ${capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'])} ${tag}`); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`\nRelease failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} From ce11d4b938663d87748c889532c5db9c97f7dd0b Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:31:46 -0400 Subject: [PATCH 26/36] fix(vscode): ship a decodable Marketplace icon The icon was a raw Windows DIB carrying a .png extension, so the storefront rendered a broken image. Re-encodes it as a real 256x256 PNG and drops the unreferenced duplicate beside it. --- .../resources/pythinker-icon-from-ico.png | Bin 270376 -> 0 bytes .../resources/pythinker-icon-storefront.png | Bin 270376 -> 28892 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/vscode/resources/pythinker-icon-from-ico.png diff --git a/apps/vscode/resources/pythinker-icon-from-ico.png b/apps/vscode/resources/pythinker-icon-from-ico.png deleted file mode 100644 index 865d75291be427c3820b8075e6215a304842f8c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 270376 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 diff --git a/apps/vscode/resources/pythinker-icon-storefront.png b/apps/vscode/resources/pythinker-icon-storefront.png index 865d75291be427c3820b8075e6215a304842f8c4..a604484eebde217659a1232b33e7ed14082a9f7d 100644 GIT binary patch literal 28892 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≥1&gQ=u%@ITFG&MJ~JP?%I6VBDGb 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 From a01ec4097ea99702cbbeedcbdd4d0302cacc1d6e Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:33:07 -0400 Subject: [PATCH 27/36] chore(vscode): release 0.8.3 --- apps/vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 18258f6f..f8fb4180 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -3,7 +3,7 @@ "publisher": "pythoughts", "displayName": "Pythinker Code", "description": "Pythinker Code extension for VS Code", - "version": "0.8.2", + "version": "0.8.3", "private": true, "license": "Apache-2.0", "type": "module", From e1e7bba0b31aec43a6dc285af6859290e2cb4a05 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:42:13 -0400 Subject: [PATCH 28/36] fix(vscode): make publishing survive a flaky registry A timeout mid-run aborted the loop, leaving one target live, four unattempted and the release untagged. Transient failures now retry with backoff, a failed target no longer stops the others, an auth failure stops everything at once, and the summary names which targets are live so a re-run can finish the job. --- apps/vscode/scripts/ovsx-publish.mjs | 33 ++++--- apps/vscode/scripts/publish-retry.mjs | 106 ++++++++++++++++++++++ apps/vscode/scripts/release-extension.mjs | 15 ++- apps/vscode/scripts/vsix-publish.mjs | 26 ++++-- apps/vscode/test/publish-retry.test.ts | 92 +++++++++++++++++++ 5 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 apps/vscode/scripts/publish-retry.mjs create mode 100644 apps/vscode/test/publish-retry.test.ts diff --git a/apps/vscode/scripts/ovsx-publish.mjs b/apps/vscode/scripts/ovsx-publish.mjs index 05809c6b..fcf476da 100644 --- a/apps/vscode/scripts/ovsx-publish.mjs +++ b/apps/vscode/scripts/ovsx-publish.mjs @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { runLocalCli } from './local-cli.mjs'; import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { messageOf, publishEachTarget } from './publish-retry.mjs'; import { extensionRoot, isMainModule } from './vsix-targets.mjs'; import { verifyVsix } from './vsix-verify.mjs'; @@ -15,22 +16,24 @@ async function main() { if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.'); await verifyInputs(options); - for (const file of options.files) { - console.log(`Publishing verified package ${file}...`); - try { - runLocalCli('ovsx', 'ovsx', ['publish', file], { - cwd: extensionRoot, - encoding: 'utf8', - stdio: 'pipe', - }); - } catch (error) { - if (/already exists/i.test(error instanceof Error ? error.message : String(error))) { - console.log(`Package already exists: ${file}`); - continue; + await publishEachTarget({ + targets: options.targets, + files: options.files, + registry: 'Open VSX', + publishOne: (file) => { + try { + runLocalCli('ovsx', 'ovsx', ['publish', file], { + cwd: extensionRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + return 'published'; + } catch (error) { + if (/already exists/i.test(messageOf(error))) return 'skipped'; + throw error; } - throw error; - } - } + }, + }); } async function verifyInputs(options) { diff --git a/apps/vscode/scripts/publish-retry.mjs b/apps/vscode/scripts/publish-retry.mjs new file mode 100644 index 00000000..944d9af1 --- /dev/null +++ b/apps/vscode/scripts/publish-retry.mjs @@ -0,0 +1,106 @@ +// Publishing to a registry fails in three different ways, and they need three +// different responses: a flaky network call should be retried, a bad token +// should stop everything immediately, and a rejected package should fail only +// its own target so the remaining ones still ship. +const TRANSIENT_PATTERN = + /request timeout|etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|network|\b(?:429|500|502|503|504)\b|too many requests|service unavailable|gateway/i; +const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allowed|forbidden|\b403\b|authentication|invalid token|expired/i; + +export const DEFAULT_ATTEMPTS = 3; + +export function messageOf(error) { + return error instanceof Error ? error.message : String(error); +} + +/** + * `auth` aborts the whole run — every remaining target would fail identically. + * `transient` is worth retrying. `fatal` fails one target and lets the rest go. + */ +export function classifyError(error) { + const message = messageOf(error); + if (AUTH_PATTERN.test(message)) return 'auth'; + if (TRANSIENT_PATTERN.test(message)) return 'transient'; + return 'fatal'; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Runs `action`, retrying only transient failures with a widening backoff. + * Returns the action's value; rethrows the last error once attempts run out. + */ +export async function withRetry(action, options = {}) { + const attempts = options.attempts ?? DEFAULT_ATTEMPTS; + const label = options.label ?? 'operation'; + const backoffMs = options.backoffMs ?? [5000, 15000]; + + for (let attempt = 1; ; attempt += 1) { + try { + return await action(); + } catch (error) { + const kind = classifyError(error); + if (kind !== 'transient' || attempt >= attempts) { + throw error; + } + const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)]; + console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`); + console.warn(` ${messageOf(error).split('\n')[0]}`); + await delay(wait); + } + } +} + +/** + * Publishes every target, keeping going after a per-target failure so one flaky + * upload cannot strand the rest. Throws a summary naming exactly which targets + * are live and which still need a re-run, because a half-published version is + * the state that is hardest to reason about afterwards. + */ +export async function publishEachTarget({ targets, files, registry, publishOne }) { + const published = []; + const skipped = []; + const failures = []; + let abortReason = ''; + + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const file = files[index]; + if (abortReason) { + failures.push({ target, message: `not attempted (${abortReason})` }); + continue; + } + console.log(`Publishing verified package ${file}...`); + try { + const outcome = await withRetry(() => publishOne(file, target), { label: `${registry} ${target}` }); + (outcome === 'skipped' ? skipped : published).push(target); + } catch (error) { + const kind = classifyError(error); + failures.push({ target, message: messageOf(error).split('\n')[0] }); + if (kind === 'auth') { + abortReason = 'aborted after an authentication failure'; + } + } + } + + summarize({ registry, published, skipped, failures }); + if (failures.length > 0) { + throw new Error( + `${registry}: ${failures.length} of ${targets.length} target(s) failed. ` + + `Published targets are live and will be skipped on a re-run — fix the cause and run the publish command again.`, + ); + } + return { published, skipped }; +} + +function summarize({ registry, published, skipped, failures }) { + console.log(`\n${registry} summary:`); + if (published.length > 0) console.log(` published: ${published.join(', ')}`); + if (skipped.length > 0) console.log(` already published: ${skipped.join(', ')}`); + for (const failure of failures) { + console.log(` FAILED ${failure.target}: ${failure.message}`); + } +} diff --git a/apps/vscode/scripts/release-extension.mjs b/apps/vscode/scripts/release-extension.mjs index 02053893..4943e55b 100644 --- a/apps/vscode/scripts/release-extension.mjs +++ b/apps/vscode/scripts/release-extension.mjs @@ -127,7 +127,20 @@ async function main() { run('git', ['add', 'apps/vscode/package.json']); run('git', ['commit', '-m', `chore(vscode): release ${version}`]); - run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } }); + try { + run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } }); + } catch (error) { + // The version bump is already committed and some targets may already be + // live, so say exactly how to finish rather than leaving it to be worked out. + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n\n` + + `${version} is partly published and NOT tagged. The publish summary above lists which\n` + + `targets are live; published ones are skipped on a re-run. Finish with:\n` + + ` pnpm --filter pythinker-code run publish:vsix\n` + + ` git tag -a ${tag} -m "Pythinker Code VS Code extension ${version}"\n` + + `Do not bump the version again — ${version} is already consumed.`, + ); + } const ovsxPat = process.env.OVSX_PAT || keychainSecret(OVSX_KEYCHAIN_SERVICE); if (ovsxPat) { diff --git a/apps/vscode/scripts/vsix-publish.mjs b/apps/vscode/scripts/vsix-publish.mjs index 4949f293..9f51453f 100644 --- a/apps/vscode/scripts/vsix-publish.mjs +++ b/apps/vscode/scripts/vsix-publish.mjs @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { runLocalCli } from './local-cli.mjs'; import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { publishEachTarget } from './publish-retry.mjs'; import { extensionRoot, isMainModule } from './vsix-targets.mjs'; import { verifyVsix } from './vsix-verify.mjs'; @@ -20,15 +21,22 @@ async function main() { } await verifyInputs(options); - for (const file of options.files) { - console.log(`Publishing verified package ${file}...`); - runLocalCli( - '@vscode/vsce', - 'vsce', - ['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])], - { cwd: extensionRoot }, - ); - } + await publishEachTarget({ + targets: options.targets, + files: options.files, + registry: 'Marketplace', + publishOne: (file) => { + const result = runLocalCli( + '@vscode/vsce', + 'vsce', + ['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])], + { cwd: extensionRoot, encoding: 'utf8', stdio: 'pipe' }, + ); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + process.stdout.write(output); + return /already published/i.test(output) ? 'skipped' : 'published'; + }, + }); } async function verifyInputs(options) { diff --git a/apps/vscode/test/publish-retry.test.ts b/apps/vscode/test/publish-retry.test.ts new file mode 100644 index 00000000..130e1950 --- /dev/null +++ b/apps/vscode/test/publish-retry.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +// @ts-expect-error -- plain .mjs build script, no type declarations +import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs'; + +const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64']; +const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`); + +// Backoff is real time; every retry test overrides it so the suite stays fast. +const FAST = { backoffMs: [0, 0] }; + +describe('classifyError', () => { + it('separates the three failure kinds that need different handling', () => { + // The exact string the Marketplace returned mid-publish on 2026-08-05. + expect(classifyError(new Error('Request timeout: /_apis/gallery/publishers/pythoughts'))).toBe('transient'); + expect(classifyError(new Error('connect ECONNRESET 13.107.42.16:443'))).toBe('transient'); + expect(classifyError(new Error('Response code 503 (Service Unavailable)'))).toBe('transient'); + + // The exact string the Entra credential path returned. + expect(classifyError(new Error('{"message":"The requested operation is not allowed."}'))).toBe('auth'); + expect(classifyError(new Error('Response code 401 (Unauthorized)'))).toBe('auth'); + + expect(classifyError(new Error('Extension entrypoint(s) missing'))).toBe('fatal'); + }); +}); + +describe('withRetry', () => { + it('retries a transient failure and returns the eventual success', async () => { + const action = vi + .fn() + .mockRejectedValueOnce(new Error('Request timeout')) + .mockResolvedValueOnce('published'); + + await expect(withRetry(action, { label: 'test', ...FAST })).resolves.toBe('published'); + expect(action).toHaveBeenCalledTimes(2); + }); + + it('gives up after the attempt budget and rethrows the last error', async () => { + const action = vi.fn().mockRejectedValue(new Error('Request timeout')); + + await expect(withRetry(action, { label: 'test', attempts: 3, ...FAST })).rejects.toThrow('Request timeout'); + expect(action).toHaveBeenCalledTimes(3); + }); + + it('does not retry a non-transient failure', async () => { + const action = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)')); + + await expect(withRetry(action, { label: 'test', ...FAST })).rejects.toThrow('401'); + expect(action).toHaveBeenCalledTimes(1); + }); +}); + +describe('publishEachTarget', () => { + it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => { + const publishOne = vi.fn(async (_file: string, target: string) => { + if (target === 'darwin-arm64') throw new Error('Extension rejected'); + return 'published'; + }); + + await expect( + publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }), + ).rejects.toThrow('1 of 3 target(s) failed'); + + // The point of the change: linux-x64 is attempted even though darwin-arm64 died. + expect(publishOne.mock.calls.map((call) => call[1])).toEqual(TARGETS); + }); + + it('stops immediately on an auth failure instead of hammering every target', async () => { + const publishOne = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)')); + + await expect( + publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }), + ).rejects.toThrow('3 of 3 target(s) failed'); + + expect(publishOne).toHaveBeenCalledTimes(1); + }); + + it('treats an already-published target as success, so a re-run completes', async () => { + const publishOne = vi.fn(async (_file: string, target: string) => + target === 'darwin-x64' ? 'skipped' : 'published', + ); + + const result = await publishEachTarget({ + targets: TARGETS, + files: FILES, + registry: 'Marketplace', + publishOne, + }); + + expect(result).toEqual({ published: ['darwin-arm64', 'linux-x64'], skipped: ['darwin-x64'] }); + }); +}); From fb2690c472376a6cd9786226756c7df8322ee992 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:45:47 -0400 Subject: [PATCH 29/36] fix(tui): stop early frames anchoring to the shell cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-tui paints on requestRender before ui.start(), so frames rendered during construction and mounting were anchored at the shell cursor and the fixed layout's scroll-to-home pushed the panel border into scrollback. Rendering is now gated until the event loop starts, which runs before the main TUI mounts. The gate writes a private pi-tui field, so a test asserts both the gate and ui.start() clearing it — an upgrade that changes either fails there. --- apps/pythinker-code/src/tui/pythinker-tui.ts | 15 ++++++++++++- apps/pythinker-code/src/tui/tui-state.ts | 5 +++++ .../test/tui/create-tui-state.test.ts | 21 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 941d6c02..29071aa8 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -468,9 +468,14 @@ export class PythinkerTUI { return; } - const shouldReplayHistory = await this.initMainTui(); + // Start the loop before mounting anything: pi-tui paints on invalidate + // even before ui.start(), so mounting first anchors early frames to the + // shell cursor and startEventLoop's scroll-to-home would push the live + // frame's top rows into scrollback for good. Same order as the + // migration branch above. this.startEventLoop(); try { + const shouldReplayHistory = await this.initMainTui(); this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); this.startKeybindingsWatcher(); @@ -550,6 +555,14 @@ export class PythinkerTUI { private startEventLoop(): void { const start = (): void => { + // The fixed layout emits exactly `terminal.rows` lines per frame, and + // pi-tui's first render assumes a clean screen. Any shell output already + // on screen would scroll the frame's top rows (panel border included) + // out of view for good, so scroll the history up first and start at home. + if (this.state.layout === 'fixed') { + const rows = this.state.terminal.rows; + this.presentation.writeTerminalControl('\n'.repeat(Math.max(1, rows)) + '\u001B[H'); + } this.presentation.start(() => { this.refreshFooter(); this.state.ui.requestRender(); diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index 546515b1..65be7ecb 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -71,6 +71,11 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const terminal = new ProcessTerminal(); const ui = new TUI(terminal); + // Gate rendering until the event loop starts: pi-tui paints on requestRender + // even before ui.start() (stopped defaults to false), so construction-time + // renders would anchor frames to the shell cursor. The field is private in + // pi-tui's types; ui.start() flips it back to false. + (ui as unknown as { stopped: boolean }).stopped = true; const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index e006cb7e..25a8f9a1 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -36,6 +36,27 @@ thinkingLevel: 'off', } describe('createTUIState', () => { + it('gates rendering until the event loop starts, and ui.start() re-opens it', () => { + // The gate writes pi-tui's PRIVATE `stopped` field, and `ui.start()` clearing + // it again is what lets the TUI ever paint. If an upgrade renames the field or + // stops resetting it, the app renders nothing — a silent, total failure. This + // asserts both halves so that upgrade fails here instead of in someone's terminal. + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { continueLast: false, yolo: false, auto: false, plan: false }, + layout: 'fixed', + }); + const ui = state.ui as unknown as { stopped: boolean; start: () => void }; + + expect(ui.stopped).toBe(true); + + vi.spyOn(state.terminal, 'start').mockImplementation(() => {}); + vi.spyOn(state.terminal, 'hideCursor').mockImplementation(() => {}); + ui.start(); + + expect(ui.stopped).toBe(false); + }); + it('initializes all fields with sensible defaults', () => { const opts: PythinkerTUIOptions = { initialAppState: fakeInitialAppState(), From de40509c13ea71b1aa6e8f97d75f0c1abc4e2326 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:45:52 -0400 Subject: [PATCH 30/36] chore(deps): upgrade pi-tui to 0.83.0 --- package.json | 3 +++ pnpm-lock.yaml | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4eb27d9f..a68c7c01 100644 --- a/package.json +++ b/package.json @@ -64,5 +64,8 @@ "diff@>=6.0.0 <8.0.3": "^8.0.3", "serialize-javascript@<7.0.5": "^7.0.5" } + }, + "dependencies": { + "@earendil-works/pi-tui": "0.83.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fcc174e..f5b96dfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,10 @@ overrides: importers: .: + dependencies: + '@earendil-works/pi-tui': + specifier: 0.83.0 + version: 0.83.0 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.3 @@ -476,7 +480,7 @@ importers: version: 3.5.2(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) vitest: specifier: 4.1.4 - version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) docs: dependencies: @@ -1324,6 +1328,10 @@ packages: resolution: {integrity: sha512-OMEe+Zt8oQYi/rCq3upxsTlIScWL0FPhXwQus34TbQb3EmTx88S7Uzx32JxvQiEeWOw8eDCdJf2PBUBE9r6wIg==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-tui@0.83.0': + resolution: {integrity: sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -10451,6 +10459,11 @@ snapshots: get-east-asian-width: 1.6.0 marked: 18.0.5 + '@earendil-works/pi-tui@0.83.0': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -19069,7 +19082,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)): + vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) From 79ce0d6149c1df4a2bc55a793b02311890e64c4c Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:50:27 -0400 Subject: [PATCH 31/36] fix(vscode): list skills in the slash menu before a session exists Skill resolution was reachable only through a live session, so a freshly opened panel showed the built-in commands alone and skills appeared only after the first message. Resolution now also runs at workspace scope, using the same roots a session resolves, and the panel calls that when it has no session yet. A live session is still preferred, since only it knows its MCP prompts. --- apps/vscode/src/handlers/config.handler.ts | 15 ++++--- packages/agent-core/src/rpc/core-api.ts | 5 +++ packages/agent-core/src/rpc/core-impl.ts | 15 +++++++ packages/agent-core/src/skill/index.ts | 1 + packages/agent-core/src/skill/workspace.ts | 52 ++++++++++++++++++++++ packages/node-sdk/src/pythinker-harness.ts | 6 +++ packages/node-sdk/src/rpc.ts | 6 +++ 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 packages/agent-core/src/skill/workspace.ts diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 2eea7d7f..4804b2eb 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -89,15 +89,20 @@ const getModels: Handler = async (_, ctx) => { }; /** - * The skill catalog is session-scoped in the engine, so the list is empty until - * a session exists. `bridge-handler` re-broadcasts the commands once one is - * created, which is what fills the menu on a cold start. + * Skills are resolved from the workspace, not from a session, so a panel that + * has not sent a message yet still lists them. A live session is preferred when + * there is one: only it can report the prompts of its MCP connections. */ export const getSlashCommands: Handler = async (_, ctx) => { const session = ctx.getSession()?.session; - if (session === undefined) return SLASH_COMMANDS; try { - const { commands } = buildSkillSlashCommands(await session.listSkills()); + const skills = + session !== undefined + ? await session.listSkills() + : ctx.workDir !== null + ? await ctx.harness.listWorkspaceSkills(ctx.workDir) + : []; + const { commands } = buildSkillSlashCommands(skills); return [...SLASH_COMMANDS, ...commands.map(toSlashCommandInfo)]; } catch (error) { ctx.logError("Unable to list skills", error); diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 4aa910d2..87666649 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -387,6 +387,10 @@ export interface ListAgentProfilesPayload { readonly workDir: string; } +export interface ListWorkspaceSkillsPayload { + readonly workDir: string; +} + export interface AgentProfileSummary { readonly name: string; readonly description?: string; @@ -494,6 +498,7 @@ export interface CoreAPI extends SessionAPIWithId { getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; listOutputStyles: (payload: ListOutputStylesPayload) => OutputStyleCatalog; listAgentProfiles: (payload: ListAgentProfilesPayload) => AgentProfileCatalog; + listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => readonly SkillSummary[]; setPythinkerConfig: (payload: SetPythinkerConfigPayload) => PythinkerConfig; replacePythinkerConfig: (payload: ReplacePythinkerConfigPayload) => PythinkerConfig; removePythinkerProvider: (payload: RemovePythinkerProviderPayload) => PythinkerConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index a4886de3..8c3cb109 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -35,6 +35,7 @@ import { import { FLAG_DEFINITIONS, FlagResolver, type ExperimentalFeatureState } from '../flags'; import type { Logger } from '../logging/types'; import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; +import { listWorkspaceSkills } from '../skill/workspace'; import { DEFAULT_AGENT_PROFILES, loadAgentProfilesFromDirectories, @@ -92,6 +93,7 @@ import type { InstallPluginPayload, ListSessionsPayload, ListAgentProfilesPayload, + ListWorkspaceSkillsPayload, ListOutputStylesPayload, McpServerInfo, SkillActivationResult, @@ -615,6 +617,19 @@ export class PythinkerCore implements PromisableMethods { return this.loadAgentProfileCatalog(requiredWorkDir('listAgentProfiles', workDir)); } + // Resolves the same roots a session would, so a caller that has no session yet + // (a freshly opened editor panel) still sees the workspace's real skill list. + async listWorkspaceSkills({ + workDir, + }: ListWorkspaceSkillsPayload): Promise { + await this.pluginsReady; + this.assertPluginsLoaded(); + return listWorkspaceSkills({ + workDir: requiredWorkDir('listWorkspaceSkills', workDir), + ...this.resolveSessionSkillConfig(this.readConfigForWrite()), + }); + } + async setPythinkerConfig(input: SetPythinkerConfigPayload): Promise { const config = mergeConfigPatch(this.readConfigForWrite(), input); return this.writePythinkerConfig(config); diff --git a/packages/agent-core/src/skill/index.ts b/packages/agent-core/src/skill/index.ts index 924027bf..577f10e0 100644 --- a/packages/agent-core/src/skill/index.ts +++ b/packages/agent-core/src/skill/index.ts @@ -3,3 +3,4 @@ export * from './parser'; export * from './registry'; export * from './scanner'; export * from './types'; +export * from './workspace'; diff --git a/packages/agent-core/src/skill/workspace.ts b/packages/agent-core/src/skill/workspace.ts new file mode 100644 index 00000000..3ec5e434 --- /dev/null +++ b/packages/agent-core/src/skill/workspace.ts @@ -0,0 +1,52 @@ +import { homedir } from 'node:os'; + +import { registerBuiltinSkills } from './builtin'; +import { SessionSkillRegistry } from './registry'; +import { resolveSkillRoots } from './scanner'; +import { summarizeSkill, type SkillRoot, type SkillSummary } from './types'; + +/** + * Field-for-field the resolution inputs a session uses, so a caller can pass the + * config it already assembles for sessions. Declared here rather than imported + * from the session layer, which depends on this one. + */ +export interface ListWorkspaceSkillsOptions { + /** Directory the skills are resolved for, as a session's cwd would be. */ + readonly workDir: string; + readonly userHomeDir?: string; + /** Brand data dir (PYTHINKER_CODE_HOME); user brand skills live under `/skills`. */ + readonly brandHomeDir?: string; + readonly explicitDirs?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly pluginSkillRoots?: readonly SkillRoot[]; + readonly mergeAllAvailableSkills?: boolean; + readonly builtinDir?: string; + readonly onWarning?: (message: string, cause?: unknown) => void; +} + +/** + * The skill catalog a workspace resolves to, without opening a session. + * + * A session's own `listSkills` additionally reports the prompts of its MCP + * connections; those belong to a live session and are absent here. + */ +export async function listWorkspaceSkills( + options: ListWorkspaceSkillsOptions, +): Promise { + const registry = new SessionSkillRegistry({ onWarning: options.onWarning }); + const roots = await resolveSkillRoots({ + paths: { + userHomeDir: options.userHomeDir ?? homedir(), + brandHomeDir: options.brandHomeDir, + workDir: options.workDir, + }, + explicitDirs: options.explicitDirs, + extraDirs: options.extraDirs, + pluginSkillRoots: options.pluginSkillRoots, + mergeAllAvailableSkills: options.mergeAllAvailableSkills, + builtinDir: options.builtinDir, + }); + await registry.loadRoots(roots); + registerBuiltinSkills(registry); + return registry.listSkills().map(summarizeSkill); +} diff --git a/packages/node-sdk/src/pythinker-harness.ts b/packages/node-sdk/src/pythinker-harness.ts index 48a4b099..b0087f1a 100644 --- a/packages/node-sdk/src/pythinker-harness.ts +++ b/packages/node-sdk/src/pythinker-harness.ts @@ -25,6 +25,7 @@ import type { RenameSessionInput, ResumeSessionInput, SessionSummary, + SkillSummary, TelemetryClient, TelemetryContextPatch, TelemetryProperties, @@ -235,6 +236,11 @@ export class PythinkerHarness { return this.rpc.listAgentProfiles(workDir); } + /** The workspace's skills without opening a session; excludes session-only MCP prompts. */ + async listWorkspaceSkills(workDir: string): Promise { + return this.rpc.listWorkspaceSkills(workDir); + } + async ensureConfigFile(): Promise { await this.ensureConfigFileImpl(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index dcd13a14..5f8b9318 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -249,6 +249,12 @@ export abstract class SDKRpcClientBase { return rpc.listAgentProfiles({ workDir }); } + /** The workspace's skills without opening a session; excludes session-only MCP prompts. */ + async listWorkspaceSkills(workDir: string): Promise { + const rpc = await this.getRpc(); + return rpc.listWorkspaceSkills({ workDir }); + } + async setConfig(input: PythinkerConfigPatch): Promise { const rpc = await this.getRpc(); return rpc.setPythinkerConfig(input); From 725b273a808cfb3f665533387ade119086291364 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:50:46 -0400 Subject: [PATCH 32/36] chore: add changesets for the skills, TUI and publishing fixes --- .changeset/session-free-skill-listing.md | 5 +++++ .changeset/tui-render-gate.md | 5 +++++ .changeset/vscode-chat-logo-and-slash-insert.md | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-free-skill-listing.md create mode 100644 .changeset/tui-render-gate.md diff --git a/.changeset/session-free-skill-listing.md b/.changeset/session-free-skill-listing.md new file mode 100644 index 00000000..f09bb5df --- /dev/null +++ b/.changeset/session-free-skill-listing.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Resolve a workspace's skills without opening a session, so an editor panel can list them before its first message. diff --git a/.changeset/tui-render-gate.md b/.changeset/tui-render-gate.md new file mode 100644 index 00000000..1b5a0e5e --- /dev/null +++ b/.changeset/tui-render-gate.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop the fixed-layout TUI anchoring its first frames to the shell cursor, which pushed the panel border into scrollback. diff --git a/.changeset/vscode-chat-logo-and-slash-insert.md b/.changeset/vscode-chat-logo-and-slash-insert.md index 4d885681..b640cc26 100644 --- a/.changeset/vscode-chat-logo-and-slash-insert.md +++ b/.changeset/vscode-chat-logo-and-slash-insert.md @@ -2,4 +2,4 @@ "pythinker-code": patch --- -Show the assistant logo beside replies without breaking the step timeline, and complete a picked slash command in the input instead of sending it on its own. +Show the assistant logo beside replies without breaking the step timeline, complete a picked slash command in the input instead of sending it on its own, ship a decodable Marketplace icon, and retry transient registry failures when publishing. From d6491e251d8c7ee9ef20a2d2e5ec3d93d188fc9b Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:51:33 -0400 Subject: [PATCH 33/36] chore(deps): align the pi-tui range across the workspace --- apps/pythinker-code/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 19 +++++-------------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index b1a92a3c..448d1b28 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -95,7 +95,7 @@ "web-tree-sitter": "0.25.10" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.81.1", + "@earendil-works/pi-tui": "^0.83.0", "@pythoughts/acp-adapter": "workspace:^", "@pythoughts/dashboard-server": "workspace:^", "@pythoughts/dashboard-web": "workspace:*", diff --git a/package.json b/package.json index a68c7c01..5979aecb 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,6 @@ } }, "dependencies": { - "@earendil-works/pi-tui": "0.83.0" + "@earendil-works/pi-tui": "^0.83.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5b96dfe..5f9460ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ importers: .: dependencies: '@earendil-works/pi-tui': - specifier: 0.83.0 + specifier: ^0.83.0 version: 0.83.0 devDependencies: '@arethetypeswrong/cli': @@ -169,8 +169,8 @@ importers: version: 0.25.10 devDependencies: '@earendil-works/pi-tui': - specifier: ^0.81.1 - version: 0.81.1 + specifier: ^0.83.0 + version: 0.83.0 '@pythoughts/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter @@ -480,7 +480,7 @@ importers: version: 3.5.2(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) vitest: specifier: 4.1.4 - version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) docs: dependencies: @@ -1324,10 +1324,6 @@ packages: '@dotenvx/primitives@0.8.0': resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} - '@earendil-works/pi-tui@0.81.1': - resolution: {integrity: sha512-OMEe+Zt8oQYi/rCq3upxsTlIScWL0FPhXwQus34TbQb3EmTx88S7Uzx32JxvQiEeWOw8eDCdJf2PBUBE9r6wIg==} - engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@0.83.0': resolution: {integrity: sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA==} engines: {node: '>=22.19.0'} @@ -10454,11 +10450,6 @@ snapshots: '@dotenvx/primitives@0.8.0': {} - '@earendil-works/pi-tui@0.81.1': - dependencies: - get-east-asian-width: 1.6.0 - marked: 18.0.5 - '@earendil-works/pi-tui@0.83.0': dependencies: get-east-asian-width: 1.6.0 @@ -19082,7 +19073,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)): + vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)) From 580a17402b052d921a0d02f104b3535c4e05f255 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:54:38 -0400 Subject: [PATCH 34/36] chore: refresh the flake pnpmDeps hash for the pi-tui upgrade --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b0511937..27c405f1 100644 --- a/flake.nix +++ b/flake.nix @@ -148,7 +148,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-3bo6StR6vVFZ6ur9p67gIhEzpWzUZ+JzW6O50k13oag="; + hash = "sha256-9DehARrDfyGqxEqDmfeYTw1tIFqNQVxpOpiaW6QY4Zw="; }; nativeBuildInputs = [ From d76fa06169c7f2041dab3a7e449bcf5a5e4f9a69 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 15:59:11 -0400 Subject: [PATCH 35/36] fix(vscode): address PR review findings A skill-catalog failure rejected out of the slash parser, whose caller awaits it outside any try block, so every message starting with "/" was dropped. It now degrades to the skill prefix check, matching how an unlisted skill is handled. Selecting a different provider, or leaving the form, kept the API key already typed, so a key entered for one provider could be saved under another's id. --- apps/vscode/src/handlers/slash-command.ts | 8 ++++++-- .../pythinker-harness.integration.test.ts | 20 +++++++++++++++++++ .../src/components/ProvidersModal.tsx | 13 +++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index 4a54f6ba..f9ccafe3 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -59,10 +59,14 @@ export async function parseHostSlashCommand( const args = match[2]?.trim() ?? ""; if (HOST_COMMANDS.has(name)) return { name, args, raw }; - if (listSkills === undefined) { + const skills = listSkills === undefined ? undefined : await listSkills().catch(() => undefined); + if (skills === undefined) { + // The parser runs on every message that starts with "/", so a catalog + // failure must degrade to the prefix check rather than reject and take the + // whole send down with it. return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined; } - const { commandMap } = buildSkillSlashCommands(await listSkills()); + const { commandMap } = buildSkillSlashCommands(skills); const skillName = commandMap.get(name) ?? commandMap.get(match[1]!); if (skillName !== undefined) return { name, args, raw, skillName }; // A skill the catalog no longer lists still reaches the engine, which reports diff --git a/apps/vscode/test/pythinker-harness.integration.test.ts b/apps/vscode/test/pythinker-harness.integration.test.ts index 8a1f14ec..61677746 100644 --- a/apps/vscode/test/pythinker-harness.integration.test.ts +++ b/apps/vscode/test/pythinker-harness.integration.test.ts @@ -386,6 +386,26 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home) await expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).resolves.toBeUndefined(); }); + it("degrades to the skill prefix when the skill catalog fails", async () => { + // The parser runs on every message starting with "/", and its caller in + // chat.handler awaits it outside any try block — a rejection here silently + // drops the user's message instead of sending it. + const listSkills = () => Promise.reject(new Error("engine unavailable")); + + await expect(parseHostSlashCommand("/skill:review carefully", listSkills)).resolves.toEqual({ + name: "skill:review", + args: "carefully", + raw: "/skill:review carefully", + skillName: "review", + }); + await expect(parseHostSlashCommand("/plan on", listSkills)).resolves.toEqual({ + name: "plan", + args: "on", + raw: "/plan on", + }); + await expect(parseHostSlashCommand("/unknown-thing", listSkills)).resolves.toBeUndefined(); + }); + it("resolves a built-in skill invoked under its bare name", async () => { const listSkills = async () => [ { name: "gen-changesets", description: "", path: "/s", source: "builtin", type: "prompt" }, diff --git a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx index cdcb70f7..8c487cd0 100644 --- a/apps/vscode/webview-ui/src/components/ProvidersModal.tsx +++ b/apps/vscode/webview-ui/src/components/ProvidersModal.tsx @@ -318,6 +318,9 @@ function AddProviderPanel({ setSelected(entry); setUseEnvVar(false); setDefaultModel(undefined); + // A key typed for one provider must not be submittable + // under another provider's id. + setApiKey(""); }} className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded hover:bg-muted/60 text-left" > @@ -408,7 +411,15 @@ function AddProviderPanel({ {selected !== undefined && (
-