diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index c6efe6f39..19a38148c 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -18,16 +18,31 @@ observed generation window, not server hardware throughput. ## Terminal titles and notifications -Terminal titles show the current state, session name and MCode, for example -`Needs approval | Fix login | MCode`. Renaming or switching a session updates the -title. Unnamed sessions use the project name and a short session ID. Titles are -cleared when MCode exits or suspends and reapplied when it resumes. - -Configure these presentation settings in the MCode data directory's `config.yaml`: +The interactive TUI also reports [OSC 7501 program status](https://www.superlogical.com/rex/docs/build/program-status) +with `app=kcode`: idle, working, done, error, or blocked on permission, a question, +or login. It reuses the current session's runtime status, including queue handoffs, +compaction and retries, and does not depend on status line items. Cancellation and +suspension report idle; resume reapplies the current status. Completed and failed +results remain reported when the TUI exits. Reports contain only `state`, `kind` and +`app`, and are sent on changes, without a heartbeat or capability query, as +permitted by protocol revision 0.4. Supporting terminals decide how to display +them; other terminals should ignore the sequence. The protocol treats each +multiplexer pane as its own terminal, so Kinetick Code does not wrap reports for tmux +passthrough; the multiplexer must implement OSC 7501 for its panes. Redirected +output, headless exec and ACP do not emit these reports. Set +`tui.programStatus: false` to disable them. Terminal title and notification +settings remain independent of program status. + +Terminal titles show the session title, or `Kinetick Code` when the session is +untitled. Renaming or switching a session updates the title. Agent status does +not rewrite the title. + +Configure these presentation settings in the Kinetick Code data directory's `config.yaml`: ```yaml tui: terminalTitle: [status, session-name, app-name] + programStatus: true notifications: when: unfocused method: auto @@ -38,7 +53,7 @@ Title items can be ordered or omitted; `project-name` is also available. Set `terminalTitle` to `null` or `[]` to disable title updates. Unknown items are ignored. Notification `when` accepts `unfocused`, `always` or `never`; `method` accepts `auto`, `osc9`, `osc777` or `bel`. Omitting `events` enables all four events; `[]` -disables them. Apply configuration changes by restarting MCode. +disables them. Apply configuration changes by restarting Kinetick Code. Notifications identify the session and suppress duplicates. Completion waits for the session's queue to finish; failed turns and requests for input can notify diff --git a/packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts b/packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts index ccfbf42f9..ab0461d3f 100644 --- a/packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts +++ b/packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts @@ -605,6 +605,36 @@ describe("withLLMRetry", () => { expect(error?.type === "error" ? error.error.errorMessage : undefined).toBe(message); }); + // A request that is too large (HTTP 413, request_too_large, the local + // pre-send body check, or a context-window overflow) is rejected again on + // every resend; the BYOK retry-all budget used to send it six times. + it.each([ + ["minimax_api", "413 Request Entity Too Large"], + ["minimax_api", '413 {"type":"error","error":{"type":"request_too_large","message":"Request exceeds the maximum allowed number of bytes."}}'], + ["custom_provider:work", "413 Payload Too Large"], + ["custom_provider:work", "413 status code (no body)"], + ["minimax_api", "Request body too large: 70000000 bytes exceeds client limit 67108864 bytes."], + ["minimax_api", "400 invalid params, context window exceeds limit (2013)"], + ["custom_provider:work", '400 {"error":{"message":"prompt is too long: 600000 tokens > 512000 maximum"}}'], + ["minimax", "413 Request Entity Too Large"], + ["minimax", "Request body too large: 70000000 bytes exceeds client limit 67108864 bytes."], + ])("sends an oversized request from %s only once: %s", async (provider, message) => { + let attempts = 0; + const sleep = vi.fn(async () => {}); + const inner = (async () => { + attempts += 1; + return errorStream(message); + }) as StreamFn; + const wrapped = withLLMRetry(inner, retryOptions({ sleep })); + + const events = await collectEvents(await wrapped(fakeModel(provider), CONTEXT, {})); + + expect(attempts).toBe(1); + expect(sleep).not.toHaveBeenCalled(); + const error = events.find((event) => event.type === "error"); + expect(error?.type === "error" ? error.error.errorMessage : undefined).toBe(message); + }); + it.each([ "[Error] 400 Bad Request", "503 Service Unavailable: too many images queued, try again", @@ -1181,4 +1211,40 @@ describe("deterministic request rejection classification", () => { expect(rejection("401 invalid api key")).toBe(false); expect(classifyLLMRequestRejectionMessage("invalid_request_error without a status")).toBeUndefined(); }); + + it("treats HTTP 413, byte-limit and context-overflow rejections as deterministic", () => { + const oversized = rejection; + expect(oversized("413 Request Entity Too Large")).toBe(true); + expect(oversized("anything", 413)).toBe(true); + expect(oversized('{"type":"error","error":{"type":"request_too_large","message":"too big"}}')).toBe(true); + expect(oversized("Request body too large: 9 bytes exceeds client limit 8 bytes.")).toBe(true); + expect(oversized("400 invalid params, context window exceeds limit (2013)")).toBe(true); + expect(oversized("prompt is too long: 213462 tokens > 200000 maximum")).toBe(true); + expect(rejection("413 Payload Too Large")).toBe(true); + }); + + it("does not treat transient or unrelated size-like errors as deterministic", () => { + const oversized = rejection; + // The bare MiniMax 2013 code is a generic invalid-params code. + expect(oversized("400 invalid params (2013)")).toBe(false); + expect(oversized("400 Bad Request")).toBe(false); + expect(oversized("prompt is too long", 503)).toBe(false); + expect(oversized("429 rate limited: request too large for TPM")).toBe(false); + expect(oversized("Request body too large: timeout while uploading")).toBe(false); + expect(oversized("401 invalid api key")).toBe(false); + }); + + it("classifies the BYOK-attributed form of an oversized request", () => { + const byok = (detail: string) => + `BYOK upstream error: ${JSON.stringify({ + errorCode: LLM_ERROR_CODES.LLM_UPSTREAM_ERROR, + message: `BYOK provider custom_provider:fixture upstream error: ${detail}`, + errorSource: "byok_upstream", + errorDetail: detail, + errorProviderId: "custom_provider:fixture", + })}`; + expect(rejection(byok("413 Payload Too Large"))).toBe(true); + expect(rejection(byok("400 invalid params, context window exceeds limit (2013)"))).toBe(true); + expect(rejection(byok("503 Service Unavailable"))).toBe(false); + }); }); diff --git a/packages/agent-modules/system-reminder/src/blocks.ts b/packages/agent-modules/system-reminder/src/blocks.ts index ce7630755..d8ad240ce 100644 --- a/packages/agent-modules/system-reminder/src/blocks.ts +++ b/packages/agent-modules/system-reminder/src/blocks.ts @@ -217,7 +217,9 @@ export function buildSlimAgentContextBlock(env: AgentEnv): string { lines.push(` agentName: ${env.agentName}`); } lines.push(` SESSION ROLE: ${sessionRole}`); - lines.push(` YOUR SESSION ID: ${env.sessionId}`); + if (!env.sessionIdInContext) { + lines.push(` YOUR SESSION ID: ${env.sessionId}`); + } if (env.parentSessionId) { lines.push(` PARENT SESSION: ${env.parentSessionId}`); lines.push(buildParentResultDeliveryLine(env, isCloud)); diff --git a/packages/agent-modules/system-reminder/src/types.ts b/packages/agent-modules/system-reminder/src/types.ts index 1169dea4e..e42525f89 100644 --- a/packages/agent-modules/system-reminder/src/types.ts +++ b/packages/agent-modules/system-reminder/src/types.ts @@ -134,6 +134,13 @@ export interface AgentEnv { userConfiguredName?: string | undefined; agentRole: string; sessionId: string; + /** + * Host-verified fact that the model-visible context already carries + * `YOUR SESSION ID` (e.g. an earlier user message since the latest + * compaction). When true, the slim per-turn block omits the line to save + * tokens. Absent/false keeps the line (cloud and legacy callers). + */ + sessionIdInContext?: boolean | undefined; /** Session type — 0=Branch, 1=Root (formerly Main). */ sessionType?: number | undefined; /** Parent session ID — set when this is a child session. */ diff --git a/packages/config/src/tui-config.ts b/packages/config/src/tui-config.ts index 5c71614a1..58dd30114 100644 --- a/packages/config/src/tui-config.ts +++ b/packages/config/src/tui-config.ts @@ -46,6 +46,8 @@ export interface TuiConfig { }; /** Show contextual Tips in the idle composer header. Defaults to true. */ showTips?: boolean; + /** Report OSC 7501 program status to the terminal. Defaults to true. */ + programStatus?: boolean; /** * Status line items, in display order. * @@ -115,6 +117,7 @@ export function parseTuiConfig(raw: Record): TuiConfig { } : {}), ...(typeof tui.showTips === 'boolean' ? { showTips: tui.showTips } : {}), + ...(typeof tui.programStatus === 'boolean' ? { programStatus: tui.programStatus } : {}), ...(notifications ? { notifications } : {}), ...(statusLine ? { statusLine } : {}), ...(customStatusLine ? { customStatusLine } : {}), diff --git a/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts b/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts index d2189a91c..bc6a4e8ba 100644 --- a/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts +++ b/packages/local-runtime-v2/src/compat/v1/agent-host.test.ts @@ -132,6 +132,22 @@ describe("createV1AgentHostProductCapabilities", () => { turnId: "turn-1", deferTelemetry: true, }); + await product.inputPreparation.reminders.buildSystem({ + session: { sessionId: "session-1" }, + agent: { agentName: "mavis", resourceAgentName: "main" }, + agentConfig: {}, + promptText: "again", + turnId: "turn-2", + sessionIdInContext: true, + } as never); + expect(hosted.reminders.buildSystem).toHaveBeenLastCalledWith({ + session: { sessionId: "session-1" }, + resourceAgentName: "main", + promptText: "again", + turnId: "turn-2", + deferTelemetry: true, + sessionIdInContext: true, + }); expect(product.executor.fileChanges.begin).toBe(hosted.fileChanges.begin); expect(product.runner.contextUsage).toBe(hosted.contextUsage); expect(product.runner.logger).toBe(hosted.runnerLogger); diff --git a/packages/local-runtime-v2/src/compat/v1/agent-host.ts b/packages/local-runtime-v2/src/compat/v1/agent-host.ts index f7e638d2b..c793567f0 100644 --- a/packages/local-runtime-v2/src/compat/v1/agent-host.ts +++ b/packages/local-runtime-v2/src/compat/v1/agent-host.ts @@ -195,6 +195,7 @@ export function createV1AgentHostProductCapabilities( promptText: input.promptText, turnId: input.turnId, deferTelemetry: true, + ...(input.sessionIdInContext ? { sessionIdInContext: true } : {}), ...(model ? { model } : {}), }); }, diff --git a/packages/local-runtime-v2/src/service/model-system/README.md b/packages/local-runtime-v2/src/service/model-system/README.md index 80324b19a..0b44c9bad 100644 --- a/packages/local-runtime-v2/src/service/model-system/README.md +++ b/packages/local-runtime-v2/src/service/model-system/README.md @@ -63,7 +63,7 @@ Official OpenCode Go HTTPS endpoints under `https://opencode.ai/zen/go` receive 登录完成后,`CodexOAuthManager` 从当前 profile 的 `codex-auth.json` 获取凭据,复用 `AuthStorage` 的刷新机制和宿主 `fetch`,请求 -`https://chatgpt.com/backend-api/codex/models?client_version=0.153.0`。 +`https://chatgpt.com/backend-api/codex/models?client_version=0.162.0`。 `connectivity/codex-model-discovery.ts` 负责这个独立的 Codex 协议。兼容版本来自已验证的 Codex 客户端协议,与 MiniMax Code 产品版本独立;模型 ID 和思考等级由响应提供。OAuth 解析复用可配置思考协议,将远端等级原样映射到请求; `ultra` 等新增等级不依赖 Pi 的静态等级枚举。 diff --git a/packages/local-runtime-v2/src/service/model-system/codex-oauth.test.ts b/packages/local-runtime-v2/src/service/model-system/codex-oauth.test.ts index 44d1c111c..2d4d63115 100644 --- a/packages/local-runtime-v2/src/service/model-system/codex-oauth.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/codex-oauth.test.ts @@ -795,7 +795,7 @@ describe("Codex OAuth profile credentials", () => { fetch: fetchImpl, }); expect(fetchImpl).toHaveBeenCalledWith( - expect.stringContaining("/codex/models?"), + "https://chatgpt.com/backend-api/codex/models?client_version=0.162.0", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer fresh", diff --git a/packages/local-runtime-v2/src/service/model-system/connectivity/codex-model-discovery.ts b/packages/local-runtime-v2/src/service/model-system/connectivity/codex-model-discovery.ts index e9b0a2245..d8547fd4d 100644 --- a/packages/local-runtime-v2/src/service/model-system/connectivity/codex-model-discovery.ts +++ b/packages/local-runtime-v2/src/service/model-system/connectivity/codex-model-discovery.ts @@ -2,7 +2,7 @@ import type { LocalCustomProviderConfig, LocalModelConfig } from '../contracts.j const CODEX_BASE_URL = 'https://chatgpt.com/backend-api'; // Codex model discovery negotiates against the Codex client version, independent of our app version. -const CODEX_CATALOG_CLIENT_VERSION = '0.153.0'; +const CODEX_CATALOG_CLIENT_VERSION = '0.162.0'; export interface CodexModelCredentials { access: string; diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts index 0df3a6188..f07718332 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts @@ -45,6 +45,7 @@ export class NativeLocalTurnExecutionPreparationSource< ...(input.execution.desktopCapabilities ? { desktopCapabilities: input.execution.desktopCapabilities } : {}), + history: (input.execution.runnerHistory ?? input.execution.history)?.messages ?? [], }); const browserAssets = [...(prepared.browserAssets ?? [])]; const context = createToolContext( diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts index ba864a217..1d8a0b6c0 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts @@ -21,6 +21,7 @@ import { } from '../canonical-user-input.js'; import { createBackgroundTaskHostMetadata } from '../history/background/host-metadata.js'; import { assertAgentHostCapabilityAvailable } from '../empty-dependencies.js'; +import { messagesCarrySessionId } from '../compaction/session-identity.js'; import { prepareLocalInlineMedia, type LocalInlineMediaCandidate, @@ -86,6 +87,12 @@ export interface LocalTurnReminderFacts { readonly promptText: string; readonly turnId: string; readonly desktopCapabilities?: AgentHostTurnCapabilityView; + /** + * True when a user message in the model-visible history since the latest + * compaction already contains this session's ID, so per-turn reminders may + * omit it. False on the first turn and after a compaction dropped it. + */ + readonly sessionIdInContext?: boolean; }) => Promise<{ readonly content: string; readonly diagnostic?: unknown; @@ -135,6 +142,8 @@ export interface LocalTurnInputPreparationRequest { readonly immediateSendBatch?: AgentHostExecutionRequest['immediateSendBatch']; readonly provenance: AgentHostTurnProvenance; readonly desktopCapabilities?: AgentHostTurnCapabilityView; + /** Provider-facing history before this turn's user message. */ + readonly history?: readonly unknown[]; } export interface PreparedLocalTurnInput { @@ -203,6 +212,9 @@ export class LocalTurnInputPreparer { promptText, turnId: input.lease.turnId, ...(input.desktopCapabilities ? { desktopCapabilities: input.desktopCapabilities } : {}), + ...(input.history && messagesCarrySessionId(input.history, input.session.sessionId) + ? { sessionIdInContext: true } + : {}), }); const system = validateSystemReminder(systemValue); const systemBlock = optionalReminderBlock('system', system.content); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts new file mode 100644 index 000000000..8855165d4 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts @@ -0,0 +1,142 @@ +import { streamSimple } from '@earendil-works/pi-ai'; +import { describe, expect, it } from 'vitest'; + +import { LocalModelResolver } from '../../../model-system/resolution/local-model-resolver.js'; +import { modelRefForModel } from '../../../model-system/resolution/model-ref.js'; +import type { LocalModelConfig } from '../../../model-system/contracts.js'; +import { buildLocalRequestPayloadTransform } from './local-turn-payload-transform.js'; + +// Mirrors the bundled MiniMax catalog entries that matter for thinking. +const M31_FLASH = { + name: 'M3.1-Flash-Preview', + reasoning: true, + tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 512_000, output: 128_000 }, + thinking: { + effortOptions: ['default', 'low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'default', + }, + thinking_config: { mode: 'forced_on' }, + variants: { + 'none-thinking': { thinking: { type: 'disabled' } }, + thinking: { thinking: { type: 'adaptive' } }, + }, +} as unknown as LocalModelConfig; + +const M3 = { + name: 'M3', + reasoning: true, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + limit: { context: 1_000_000, output: 128_000 }, + thinking_config: { mode: 'forced_on' }, + variants: { + 'none-thinking': { thinking: { type: 'disabled' } }, + thinking: { thinking: { type: 'adaptive' } }, + }, +} as unknown as LocalModelConfig; + +/** Resolve an API-key MiniMax model and capture the request body pi-ai actually sends. */ +async function captureWireBody(input: { + readonly provider: string; + readonly modelId: string; + readonly modelConfig: LocalModelConfig; + readonly effort?: string; + readonly minimaxModelSource?: string; +}) { + const modelRef = modelRefForModel(input.provider, input.modelId, input.modelConfig, { + ...(input.effort ? { thinking: { effort: input.effort } } : {}), + } as never); + const agentConfig = { + system_prompt: 'system', + agent_id: 'agent', + model: modelRef, + tools: [], + skills: [], + } as never; + const resolver = new LocalModelResolver({ + providerConfig: { minimax: { models: { [input.modelId]: input.modelConfig } } } as never, + byokConfigGetter: () => + ({ + ...(input.minimaxModelSource ? { minimaxModelSource: input.minimaxModelSource } : {}), + minimax_api: { apiKey: 'k', baseURL: 'https://byok.invalid/anthropic' }, + }) as never, + }); + const llm = await resolver.resolveModel({ sessionId: 's', turnId: 't', agentConfig }); + expect(llm.model.provider).toBe('minimax_api'); + expect(llm.managedProvider).not.toBe(true); + let body: Record | undefined; + const fetchImpl = (async (_url: unknown, init: { body?: unknown }) => { + body = JSON.parse(String(init.body)) as Record; + return new Response('stop here', { status: 500 }); + }) as unknown as typeof fetch; + const onPayload = buildLocalRequestPayloadTransform( + { agentConfig, llm, sessionId: 's', signal: new AbortController().signal }, + {}, + ); + const stream = streamSimple( + llm.model as never, + { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] }, + { + apiKey: 'k', + reasoning: llm.thinkingLevel ?? undefined, + fetch: fetchImpl, + onPayload, + maxRetries: 0, + } as never, + ); + for await (const _event of stream) { + // Drain until the stubbed provider error ends the stream. + } + expect(body).toBeDefined(); + return body!; +} + +describe('API-key MiniMax thinking effort', () => { + it.each(['high', 'low', 'xhigh', 'max'])( + 'keeps --effort %s on the minimax_api wire request', + async (effort) => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + effort, + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body.output_config).toEqual({ effort }); + }, + ); + + it('keeps --effort when a MiniMax model is routed through the API key source', async () => { + const body = await captureWireBody({ + provider: 'minimax', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + effort: 'high', + minimaxModelSource: 'minimax_api_key', + }); + expect(body.output_config).toEqual({ effort: 'high' }); + }); + + it('sends no effort when none was selected (default)', async () => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3.1-Flash-Preview', + modelConfig: M31_FLASH, + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body).not.toHaveProperty('output_config'); + }); + + it('sends no effort for a model without effort levels', async () => { + const body = await captureWireBody({ + provider: 'minimax_api', + modelId: 'MiniMax-M3', + modelConfig: M3, + effort: 'high', + }); + expect(body.thinking).toEqual({ type: 'adaptive' }); + expect(body).not.toHaveProperty('output_config'); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts index 941294718..842d76b30 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts @@ -67,6 +67,10 @@ export function buildLocalTurnPayloadTransform( options.managedProvider, thinkingOn, ); + const selectedEffort = + !options.managedProvider && thinkingOn + ? readSelectedThinkingEffort(modelConfig ?? {}, capabilities ?? {}) + : undefined; return (payload: unknown, model: Model): Promise => transformLocalTurnPayload({ @@ -76,6 +80,7 @@ export function buildLocalTurnPayloadTransform( fileApiPatcher, thinking, managedThinking, + ...(selectedEffort ? { selectedEffort } : {}), thinkingRequestPatch: options.thinkingRequestPatch, outputContract: options.outputContract, jsonObjectOutputEnabled: options.supportsJsonObjectOutput === true, @@ -90,7 +95,7 @@ function readManagedThinking( enabled: boolean, ) { if (!managed) return undefined; - const effort = model.thinking_effort ?? capabilities.selected_thinking_effort; + const effort = readRawThinkingEffort(model, capabilities); if ( model.thinking_level === undefined && capabilities.thinking_mode === undefined && @@ -106,6 +111,28 @@ function readManagedThinking( }; } +function readRawThinkingEffort( + model: Record, + capabilities: Record, +): unknown { + return model.thinking_effort ?? capabilities.selected_thinking_effort; +} + +/** + * The explicitly selected effort level for an API-key request. Plain on/off + * models have no level, and `default` means "let the provider decide", so + * neither is forwarded. + */ +function readSelectedThinkingEffort( + model: Record, + capabilities: Record, +): string | undefined { + const effort = readRawThinkingEffort(model, capabilities); + return typeof effort === 'string' && !['', 'on', 'off', 'default'].includes(effort) + ? effort + : undefined; +} + export function buildLocalRequestPayloadTransform( input: { readonly agentConfig: Readonly>; @@ -187,6 +214,8 @@ async function transformLocalTurnPayload(input: { readonly mode: unknown; readonly effort?: string; }; + /** User-selected effort for an unmanaged (API-key) request; see patchThinkingPayload. */ + readonly selectedEffort?: string; readonly thinkingRequestPatch: Readonly> | undefined; readonly outputContract: TurnOutputContract | undefined; readonly jsonObjectOutputEnabled: boolean; @@ -203,7 +232,7 @@ async function transformLocalTurnPayload(input: { ); const thinkingChanged = input.managedThinking ? patchManagedThinkingPayload(input.payload, input.thinking, input.managedThinking) - : patchThinkingPayload(input.payload, input.thinking, input.model); + : patchThinkingPayload(input.payload, input.thinking, input.model, input.selectedEffort); changed = videoChanged || fileApiChanged || thinkingChanged; } const requestPatchChanged = patchRequestPayload(input.payload, input.thinkingRequestPatch); @@ -389,6 +418,7 @@ function patchThinkingPayload( payload: Record, thinking: Record | undefined, model: Model, + selectedEffort: string | undefined, ): boolean { if (!thinking || !Array.isArray(payload.messages)) return false; if (model.api === 'anthropic-messages' && isDefaultThinkingModelId(model.id)) { @@ -397,7 +427,14 @@ function patchThinkingPayload( return hadThinking; } payload.thinking = { ...thinking }; - if (!shouldPreserveCustomProviderTopEffort(payload, model)) delete payload.output_config; + if (shouldPreserveCustomProviderTopEffort(payload, model)) return true; + if (selectedEffort && parseProviderId(model.provider)?.source === 'minimax_api') { + // The MiniMax API-key path speaks the same Messages dialect as the managed + // gateway; keep the selected `--effort` exactly like patchManagedThinkingPayload. + payload.output_config = { ...readRecord(payload.output_config), effort: selectedEffort }; + return true; + } + delete payload.output_config; return true; } diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/session-identity.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/session-identity.ts new file mode 100644 index 000000000..72c3c32e1 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/session-identity.ts @@ -0,0 +1,37 @@ +export const SESSION_IDENTITY_REMINDER_CUSTOM_TYPE = 'session_identity_reminder'; + +/** + * Whether a user message or session identity reminder after the latest + * compaction summary (or anywhere when never compacted) shows `sessionId`. + */ +export function messagesCarrySessionId(messages: readonly unknown[], sessionId: string): boolean { + if (!sessionId) return false; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!isRecord(message)) continue; + // Native summaries and legacy (archon) compaction markers both start a + // fresh model-visible context; anything before them was summarized away. + if (message.role === 'compactionSummary' || Object.hasOwn(message, 'archonCompaction')) { + return false; + } + const carriesId = + message.role === 'user' || + (message.role === 'custom' && message.customType === SESSION_IDENTITY_REMINDER_CUSTOM_TYPE); + if (carriesId && messageText(message.content).includes(sessionId)) return true; + } + return false; +} + +function messageText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((part: unknown) => + isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '', + ) + .join('\n'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts index 57bd119de..ec3a89c4c 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts @@ -64,6 +64,7 @@ import type { AgentHostFileChangeObservation, LocalTurnExecutionInput, } from "../runner/contracts.js"; +import { createExecutionBudgetReminder } from "../runner/execution-budget-reminder.js"; import type { AgentEventDelivery } from "../events/contracts.js"; import type { CanonicalHistoryStore } from "../history/contracts.js"; import { copyCanonicalHistoryForPiCompatibility } from "../history/canonical-history-validation.js"; @@ -4281,13 +4282,130 @@ describe("LocalRuntimeTurnExecutor input and tool safety", () => { }); }); +describe("LocalRuntimeTurnExecutor session id reminder context", () => { + const withSessionId = (sessionId: string) => + `\n\n YOUR SESSION ID: ${sessionId}\n\n\n\nhi`; + it.each([ + ["first turn (empty history)", () => [], false], + [ + "an earlier user message still shows the id", + (sessionId: string) => [ + { role: "user", content: withSessionId(sessionId), timestamp: 1 }, + { role: "user", content: "later turn", timestamp: 2 }, + ], + true, + ], + [ + "text-part user content shows the id", + (sessionId: string) => [ + { + role: "user", + content: [{ type: "text", text: withSessionId(sessionId) }], + timestamp: 1, + }, + ], + true, + ], + [ + "compaction summarized the id away", + (sessionId: string) => [ + { role: "user", content: withSessionId(sessionId), timestamp: 1 }, + { role: "compactionSummary", summary: "earlier work" }, + { role: "user", content: "kept turn", timestamp: 2 }, + ], + false, + ], + [ + "a legacy compaction marker hides earlier ids", + (sessionId: string) => [ + { role: "user", content: withSessionId(sessionId), timestamp: 1 }, + { + role: "user", + content: "summary", + archonCompaction: { schemaVersion: 1, summary: "s" }, + timestamp: 2, + }, + ], + false, + ], + [ + "a kept message after compaction shows the id", + (sessionId: string) => [ + { role: "compactionSummary", summary: "earlier work" }, + { role: "user", content: withSessionId(sessionId), timestamp: 2 }, + ], + true, + ], + ] as const)( + "tells the system reminder whether the session id is in context: %s", + async (_label, history, expected) => { + const buildSystem = vi.fn(async () => ({ content: "" })); + const base = executionInput(); + const sessionId = base.session.sessionId; + await new LocalRuntimeTurnExecutor({ + ...options(async (runInput) => { + await runInput.eventWriter.pushRuntime( + terminalEvent(RuntimeEventStatus.COMPLETED), + ); + }), + executionPreparation: new NativeLocalTurnExecutionPreparationSource( + new LocalTurnInputPreparer({ + reminders: { + buildBackground: vi.fn(async () => ({ + tasks: [], + undeliveredTotal: 0, + terminalTotal: 0, + })), + buildSystem, + confirmBackgroundTaskReads: vi.fn(async () => []), + }, + }), + { beforeToolCall: vi.fn(async () => undefined) } as never, + ), + }).execute( + executionInput({ + history: { revision: "r1", messages: history(sessionId) as never }, + }), + ); + expect(buildSystem).toHaveBeenCalledOnce(); + const call = (buildSystem.mock.calls[0] as unknown[])[0] as { + sessionIdInContext?: boolean; + }; + if (expected) expect(call.sessionIdInContext).toBe(true); + else expect(call).not.toHaveProperty("sessionIdInContext"); + }, + ); +}); + describe("LocalRuntimeTurnExecutor execution budget reminders", () => { it.each([ - { durationMs: 600_000, nearDeadlineMs: 60_000 }, - { durationMs: 120_000, nearDeadlineMs: 30_000 }, - ])( - "reminds a budgeted run ($durationMs ms) without changing history or the system prompt", - async ({ durationMs, nearDeadlineMs }) => { + { + durationMs: 600_000, + // [elapsed ms since start, expected reminder text or undefined] + steps: [ + [0, "about 10 minutes of 10 minutes total. Model generation"], + [1_000, undefined], + [299_999, undefined], + [300_000, "about 5 minutes of 10 minutes total."], + [400_000, undefined], + [450_000, "about 2 minutes of 10 minutes total."], + [479_999, undefined], + [480_000, "about 120 seconds of 10 minutes total."], + [485_000, "about 110 seconds of 10 minutes total."], + [595_000, "under 10 seconds of 10 minutes total."], + ], + }, + { + durationMs: 120_000, + steps: [ + [0, "about 120 seconds of 120 seconds total. Model generation"], + [1, "about 110 seconds of 120 seconds total."], + [90_000, "about 30 seconds of 120 seconds total."], + ], + }, + ] as const)( + "reminds a budgeted run ($durationMs ms) only at thresholds without changing history or the system prompt", + async ({ durationMs, steps }) => { let nowMs = 1_000; const executorOptions = options(async (runInput) => { const messages = [ @@ -4305,53 +4423,30 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => { model: runInput.llm.model, thinkingLevel: "off" as const, }; - const initial = await hook?.(hookInput); - expect(initial).toMatchObject({ - type: "replaceRequestMessages", - reason: "execution-budget-context", - messages: [ - messages[0], - { - role: "user", - content: expect.stringContaining(`${durationMs / 1_000} seconds`), - }, - ], - }); - nowMs = 1_000 + durationMs - nearDeadlineMs - 1; - expect( - await hook?.({ ...hookInput, phase: "iteration" }), - ).toMatchObject({ - type: "replaceRequestMessages", - messages: [ - messages[0], - { content: expect.stringContaining("Execution time remaining") }, - ], - }); - nowMs += 1; - expect( - await hook?.({ ...hookInput, phase: "iteration" }), - ).toMatchObject({ - type: "replaceRequestMessages", - messages: [ - messages[0], - { - role: "user", - content: expect.stringContaining( - `${nearDeadlineMs / 1_000} seconds`, - ), - }, - ], - }); - nowMs += 1; - expect( - await hook?.({ ...hookInput, phase: "iteration" }), - ).toMatchObject({ - type: "replaceRequestMessages", - messages: [ - messages[0], - { content: expect.stringContaining("Execution time remaining") }, - ], - }); + for (const [elapsedMs, expected] of steps) { + nowMs = 1_000 + elapsedMs; + const decision = await hook?.({ + ...hookInput, + phase: elapsedMs === 0 ? "initial" : "iteration", + }); + if (expected === undefined) { + expect(decision).toBeUndefined(); + continue; + } + expect(decision).toMatchObject({ + type: "replaceRequestMessages", + reason: "execution-budget-context", + messages: [ + messages[0], + { + role: "user", + content: expect.stringContaining( + `Execution time remaining: ${expected}`, + ), + }, + ], + }); + } nowMs = 1_000 + durationMs; expect( await hook?.({ ...hookInput, phase: "iteration" }), @@ -4382,6 +4477,61 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => { }, ); + it("retries a threshold reminder that admission rejected", async () => { + let nowMs = 1_000; + let admit = false; + const hook = createExecutionBudgetReminder( + 1_000 + 1_800_000, + () => nowMs, + () => admit, + )!; + const messages = [{ role: "user" as const, content: "hello", timestamp: 1 }]; + const hookInput = { + sessionId: "session-1", + turnId: "turn-1", + phase: "initial" as const, + messages, + canonicalMessages: messages, + model: {} as never, + thinkingLevel: "off" as const, + }; + expect(await hook(hookInput)).toBeUndefined(); + admit = true; + nowMs = 2_000; + expect(await hook(hookInput)).toMatchObject({ + messages: [ + messages[0], + { + content: + "Execution time remaining: about 29 minutes of 30 minutes total. Model generation, tools, and waiting all count against it.", + }, + ], + }); + nowMs = 1_000 + 1_800_000 - 899_000; + expect(await hook(hookInput)).toMatchObject({ + messages: [ + messages[0], + { + content: + "Execution time remaining: about 14 minutes of 30 minutes total.", + }, + ], + }); + // Jumping straight past 25% and 10% emits once for the deepest threshold. + nowMs = 1_000 + 1_800_000 - 179_000; + expect(await hook(hookInput)).toMatchObject({ + messages: [ + messages[0], + { + content: + "Execution time remaining: about 2 minutes of 30 minutes total.", + }, + ], + }); + nowMs += 1_000; + expect(await hook(hookInput)).toBeUndefined(); + }); + it("samples budget context while preserving existing request observers", async () => { let nowMs = 1_000; const existingObserver = vi.fn(); @@ -4416,14 +4566,18 @@ describe("LocalRuntimeTurnExecutor execution budget reminders", () => { expect.objectContaining({ remaining_ms: 600_000 }), "execution budget context sampled", ); + // 99 seconds later no threshold is crossed, so no tokens are spent. nowMs = 100_000; + expect( + await runInput.hooks!.beforeLlmCallHook!.at(-2)!(input), + ).toBeUndefined(); + expect(logger.info).toHaveBeenCalledOnce(); + nowMs = 301_000; const next = await runInput.hooks!.beforeLlmCallHook!.at(-2)!(input); if (next?.type !== "replaceRequestMessages") throw new Error("Expected time context"); expect(next.messages.at(-1)).toMatchObject({ - content: expect.stringContaining( - "previous model request was prepared: 99 seconds", - ), + content: expect.stringContaining("about 5 minutes of 10 minutes total."), }); await runInput.eventWriter.pushRuntime( terminalEvent(RuntimeEventStatus.COMPLETED), @@ -4676,7 +4830,7 @@ describe("LocalRuntimeTurnExecutor budget with durable reminders", () => { ).toHaveLength(keepsRequestContext ? 1 : 0); if (keepsRequestContext) { expect(requestContext).toContain( - "Execution time remaining at request preparation: 120 seconds.", + "Execution time remaining: about 120 seconds of 120 seconds total.", ); } expect(canAppend).toHaveBeenCalledOnce(); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts index c327cd28f..f6e25a3a9 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/runner/execution-budget-reminder.ts @@ -1,11 +1,25 @@ import type { PiBeforeLlmCallHook, PiTurnRunnerLogger } from '@mavis/agent-core/pi-turn-runner'; import type { ExecutionBudgetReminderAdmission } from '../execution/contracts.js'; +/** Remaining-budget fractions that each emit one reminder when first crossed. */ +const BUDGET_FRACTION_THRESHOLDS = [0.5, 0.25, 0.1] as const; +/** Inside this window every request carries a (coarse) reminder. */ +const FINAL_WINDOW_MS = 120_000; +const FINAL_WINDOW_LEVEL = BUDGET_FRACTION_THRESHOLDS.length + 1; + /** * Request-only context; the caller's existing cancellation timer remains * authoritative. `replaceRequestMessages` keeps the marker out of durable * history, so it must be registered before any hook that returns * `appendMessage` — `runBeforeLLM` ends the pipeline on a successful append. + * + * To avoid spending tokens on every model request, the reminder is emitted + * only on the first request (announces the budget), when the remaining budget + * first drops to ≤50%, ≤25% and ≤10% of the total, and on every request inside + * the final two minutes. The total budget is measured from hook creation + * (turn preparation), since requests carry no start time. Precision is whole + * minutes outside the final window and 10-second steps inside it, so the text + * does not churn between otherwise identical requests. */ export function createExecutionBudgetReminder( executionDeadlineAtMs: number | undefined, @@ -14,28 +28,24 @@ export function createExecutionBudgetReminder( logger?: Pick, ): PiBeforeLlmCallHook | undefined { if (executionDeadlineAtMs === undefined || !canAppend) return undefined; - let previousSampleAtMs: number | undefined; + const totalMs = Math.max(0, executionDeadlineAtMs - nowMs()); + let emittedLevel: number | undefined; return (input) => { try { if (input.signal?.aborted) return undefined; const now = nowMs(); const remainingMs = executionDeadlineAtMs - now; if (remainingMs <= 0) return undefined; - const elapsedMs = - previousSampleAtMs !== undefined && now >= previousSampleAtMs - ? now - previousSampleAtMs - : undefined; - previousSampleAtMs = now; - const elapsed = - elapsedMs !== undefined - ? ` Since the previous model request was prepared: ${Math.floor(elapsedMs / 1_000)} seconds (model generation, tools, and waiting).` - : ' This budget includes model generation, tools, and waiting.'; + const level = budgetLevel(remainingMs, totalMs); + const first = emittedLevel === undefined; + if (!first && level !== FINAL_WINDOW_LEVEL && level <= emittedLevel!) return undefined; const marker = { role: 'user' as const, - content: `Execution time remaining at request preparation: ${Math.ceil(remainingMs / 1_000)} seconds.${elapsed}`, + content: formatBudgetReminder(remainingMs, totalMs, first), timestamp: now, }; if (!canAppend(input, marker)) return undefined; + emittedLevel = level; try { logger?.info?.( { @@ -43,7 +53,8 @@ export function createExecutionBudgetReminder( turn_id: input.turnId, sampled_at_ms: now, remaining_ms: remainingMs, - elapsed_since_previous_request_ms: elapsedMs ?? null, + total_ms: totalMs, + budget_level: level, }, 'execution budget context sampled', ); @@ -60,3 +71,28 @@ export function createExecutionBudgetReminder( } }; } + +/** 0 = above 50%; 1..3 = crossed 50/25/10%; FINAL_WINDOW_LEVEL = final two minutes. */ +function budgetLevel(remainingMs: number, totalMs: number): number { + if (remainingMs <= FINAL_WINDOW_MS) return FINAL_WINDOW_LEVEL; + let level = 0; + BUDGET_FRACTION_THRESHOLDS.forEach((fraction, index) => { + if (remainingMs <= totalMs * fraction) level = index + 1; + }); + return level; +} + +function formatBudgetReminder(remainingMs: number, totalMs: number, first: boolean): string { + const remaining = + remainingMs > FINAL_WINDOW_MS + ? `about ${Math.floor(remainingMs / 60_000)} minutes` + : remainingMs >= 10_000 + ? `about ${Math.floor(remainingMs / 10_000) * 10} seconds` + : 'under 10 seconds'; + const total = + totalMs > FINAL_WINDOW_MS + ? `${Math.round(totalMs / 60_000)} minutes` + : `${Math.max(10, Math.round(totalMs / 10_000) * 10)} seconds`; + const scope = first ? ' Model generation, tools, and waiting all count against it.' : ''; + return `Execution time remaining: ${remaining} of ${total} total.${scope}`; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts b/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts new file mode 100644 index 000000000..e605177f7 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts @@ -0,0 +1,116 @@ +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import type { Api, Model } from '@earendil-works/pi-ai'; +import type { PiBeforeLlmCallHookInput } from '@mavis/agent-core/pi-turn-runner'; +import { describe, expect, it } from 'vitest'; + +import { validateCanonicalHistoryMessages } from '../../agent-host/history/canonical-history-validation.js'; +import { ContextUsageAnchorState } from '../../compaction/execution/usage-anchor.js'; +import { messagesCarrySessionId } from '../../agent-host/compaction/session-identity.js'; +import { createSessionIdentityReminderHook } from './session-identity-reminder.js'; + +const SESSION_ID = 'ses_identity_1'; +const MODEL = { + id: 'test-model', + name: 'test-model', + api: 'anthropic-messages', + provider: 'test-provider', + baseUrl: 'https://example.invalid', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, +} as Model; + +const hook = createSessionIdentityReminderHook(new ContextUsageAnchorState()); + +function message(value: Record): AgentMessage { + return value as unknown as AgentMessage; +} + +function user(text: string, timestamp: number): AgentMessage { + return message({ role: 'user', content: [{ type: 'text', text }], timestamp }); +} + +const idBearingUser = user( + `\n YOUR SESSION ID: ${SESSION_ID}\n\nfirst ask`, + 1, +); +const checkpoint = message({ + role: 'compactionSummary', + summary: '## Goal\nKeep going', + tokensBefore: 100, + timestamp: 2, +}); +const currentUserWithoutId = user('\n date: today\n\nnext', 3); + +function hookInput( + messages: AgentMessage[], + phase: 'initial' | 'iteration' = 'iteration', +): PiBeforeLlmCallHookInput { + return { + sessionId: SESSION_ID, + turnId: 'turn-1', + phase, + messages, + canonicalMessages: messages, + model: MODEL, + thinkingLevel: 'off', + } as PiBeforeLlmCallHookInput; +} + +describe('Session identity reminder hook', () => { + it('stays silent while the model-visible context still shows the session ID', async () => { + expect(await hook(hookInput([idBearingUser, currentUserWithoutId]))).toBeUndefined(); + }); + + it('restores the ID before the current user when initial compaction summarized it away', async () => { + const decision = await hook(hookInput([checkpoint, currentUserWithoutId], 'initial')); + + expect(decision).toMatchObject({ + type: 'appendMessage', + reason: 'session_identity_reminder', + placement: 'before-current-user', + message: { + role: 'custom', + customType: 'session_identity_reminder', + display: false, + content: expect.stringContaining(`YOUR SESSION ID: ${SESSION_ID}`), + }, + }); + if (decision?.type !== 'appendMessage') throw new Error('Expected an append decision'); + expect(() => validateCanonicalHistoryMessages([decision.message])).not.toThrow(); + }); + + it('appends at the tail mid-turn and does not repeat once the marker is in context', async () => { + const decision = await hook(hookInput([checkpoint, currentUserWithoutId])); + if (decision?.type !== 'appendMessage') throw new Error('Expected an append decision'); + expect(decision).not.toHaveProperty('placement'); + + expect( + await hook(hookInput([checkpoint, currentUserWithoutId, decision.message])), + ).toBeUndefined(); + }); +}); + +describe('messagesCarrySessionId', () => { + it('only counts user messages or identity markers after the latest compaction', () => { + expect(messagesCarrySessionId([idBearingUser, currentUserWithoutId], SESSION_ID)).toBe(true); + expect( + messagesCarrySessionId([idBearingUser, checkpoint, currentUserWithoutId], SESSION_ID), + ).toBe(false); + expect( + messagesCarrySessionId( + [ + message({ + role: 'assistant', + content: [{ type: 'text', text: SESSION_ID }], + timestamp: 4, + }), + ], + SESSION_ID, + ), + ).toBe(false); + expect(messagesCarrySessionId([idBearingUser], '')).toBe(false); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.ts b/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.ts new file mode 100644 index 000000000..1baaf0618 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.ts @@ -0,0 +1,36 @@ +import type { PiBeforeLlmCallHook } from '@mavis/agent-core/pi-turn-runner'; + +import { + SESSION_IDENTITY_REMINDER_CUSTOM_TYPE, + messagesCarrySessionId, +} from '../../agent-host/compaction/session-identity.js'; +import type { ContextUsageAnchorState } from '../../compaction/execution/usage-anchor.js'; +import { fitsReminderInFinalRequest } from './reminder-admission.js'; + +/** + * The turn reminder omits the session ID while earlier history still shows it, + * so compaction that summarizes that history away must restore the ID. + */ +export function createSessionIdentityReminderHook( + usageAnchor: ContextUsageAnchorState, +): PiBeforeLlmCallHook { + return (input) => { + if (!input.sessionId || messagesCarrySessionId(input.messages, input.sessionId)) { + return undefined; + } + const marker = { + role: 'custom' as const, + customType: SESSION_IDENTITY_REMINDER_CUSTOM_TYPE, + content: `\nYOUR SESSION ID: ${input.sessionId}\n`, + display: false as const, + timestamp: Date.now(), + }; + if (!fitsReminderInFinalRequest(input, marker, usageAnchor)) return undefined; + return { + type: 'appendMessage', + reason: SESSION_IDENTITY_REMINDER_CUSTOM_TYPE, + message: marker, + ...(input.phase === 'initial' ? { placement: 'before-current-user' as const } : {}), + }; + }; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts index 7c9db3dcd..4a418e787 100644 --- a/packages/local-runtime-v2/src/service/turn-system/production-composition.ts +++ b/packages/local-runtime-v2/src/service/turn-system/production-composition.ts @@ -84,6 +84,7 @@ import type { MessagesFileApiPatcherLogger } from './agent-host/assembly/message import { isProductionRuntimeErrorRetryable } from './runtime-error-retry-policy.js'; import { createTodoCadenceReminderHook } from './execution/reminder/todo-cadence-reminder.js'; import { createBackgroundCadenceReminder } from './execution/reminder/background-cadence-reminder.js'; +import { createSessionIdentityReminderHook } from './execution/reminder/session-identity-reminder.js'; import { fitsReminderInFinalRequest } from './execution/reminder/reminder-admission.js'; import { readSessionAgentExecutionSnapshot } from './agent-host/preparation/session-agent-execution-snapshot.js'; import { McpToolResultHistoryExternalizer } from './agent-host/execution/mcp-tool-result-history-externalizer.js'; @@ -444,6 +445,7 @@ export async function createLocalAgentHost< activatePluginHookSessionOwnership: options.pluginHookSessionOwnership.activate, afterCompactionBeforeLlmCallHooks: [ createAutomaticContextRequestFilterHook(), + createSessionIdentityReminderHook(contextUsageAnchor), createTodoCadenceReminderHook(contextUsageAnchor), ], onSteeringConsumed: options.onSteeringConsumed, diff --git a/packages/local-runtime/src/api/host-memory.ts b/packages/local-runtime/src/api/host-memory.ts index 04619603a..45cf8ce04 100644 --- a/packages/local-runtime/src/api/host-memory.ts +++ b/packages/local-runtime/src/api/host-memory.ts @@ -19,6 +19,16 @@ import type { LocalMemoryBusEmitter } from '../memory/local-memory-orchestration import type { CliSunsetNoticeEvaluator } from '../memory/cli-sunset-notice.js'; import type { PromptSkillCandidate } from '../skills/prompt-skill-lookup.js'; +/** + * Per-turn reminder clock: `Date#toString()` without seconds (e.g. + * `Fri Oct 09 2026 13:20 GMT+0800 (China Standard Time)`). Minute precision + * keeps the zone/offset the model relies on while avoiding a value that is + * unique to every second. + */ +export function formatReminderDate(nowMs: number): string { + return new Date(nowMs).toString().replace(/(\d{2}:\d{2}):\d{2}/u, '$1'); +} + export function createLocalMemorySubsystem(input: { configGetter: () => LocalRuntimeConfig; nowMs: () => number; @@ -51,7 +61,7 @@ export function createLocalMemorySubsystem(input: { const memory = input.configGetter().memory; return memory?.enabled !== false && memory?.proactive === true; }, - formatDate: () => new Date(input.nowMs()).toString(), + formatDate: () => formatReminderDate(input.nowMs()), emitBusEvent: input.emitBusEvent, nowMs: input.nowMs, ...(input.cliSunsetNotice ? { cliSunsetNotice: input.cliSunsetNotice } : {}), diff --git a/packages/local-runtime/src/api/hosted-agent-capabilities.ts b/packages/local-runtime/src/api/hosted-agent-capabilities.ts index dfbf2710d..4fc504003 100644 --- a/packages/local-runtime/src/api/hosted-agent-capabilities.ts +++ b/packages/local-runtime/src/api/hosted-agent-capabilities.ts @@ -289,6 +289,8 @@ export function createHostedAgentCapabilities( readonly promptText: string; readonly turnId: string; readonly deferTelemetry?: boolean; + /** Model-visible context already carries this session's ID. */ + readonly sessionIdInContext?: boolean; readonly model?: { readonly providerID: string; readonly modelID: string; @@ -306,6 +308,7 @@ export function createHostedAgentCapabilities( }), environmentInSystemPrompt: true, ...(input.session.skillPolicy ? { skillPolicy: input.session.skillPolicy } : {}), + ...(input.sessionIdInContext ? { sessionIdInContext: true } : {}), }; const reminderMessage = host.localDataCollector.toMessage( input.promptText, diff --git a/packages/local-runtime/src/memory/local-data-collector.ts b/packages/local-runtime/src/memory/local-data-collector.ts index 3ea28f26d..4e124ce21 100644 --- a/packages/local-runtime/src/memory/local-data-collector.ts +++ b/packages/local-runtime/src/memory/local-data-collector.ts @@ -63,6 +63,8 @@ export type LocalAgentFactsReader = { export type LocalReminderSessionInfo = SessionInfo & { environmentInSystemPrompt?: boolean; + /** See `AgentEnv.sessionIdInContext`. */ + sessionIdInContext?: boolean; parentSessionId?: string | null; rootSessionId?: string; isDefaultWorkspace?: boolean; @@ -248,6 +250,7 @@ export class LocalDataCollector { agentName, agentRole, sessionId: session.sessionId, + ...(session.sessionIdInContext ? { sessionIdInContext: true } : {}), sessionType, parentSessionId: session.parentSessionId ?? undefined, taskResultDelivery: session.taskResultDelivery, diff --git a/packages/local-runtime/test/unit/turn-reminder-context.test.ts b/packages/local-runtime/test/unit/turn-reminder-context.test.ts new file mode 100644 index 000000000..a83964070 --- /dev/null +++ b/packages/local-runtime/test/unit/turn-reminder-context.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + agentContextProvider, + buildAgentContextBlock, + buildSlimAgentContextBlock, + type AgentEnv, +} from '@mavis/system-reminder'; + +import { formatReminderDate } from '../../src/api/host-memory.js'; + +const env: AgentEnv = { + environmentInSystemPrompt: true, + workspaceDir: '/w', + agentConfigDir: '/d/agents/mavis', + agentName: 'mavis', + agentRole: 'orchestrator', + sessionId: 'sess-123', + platform: 'linux', + scene: 'local', + date: 'Fri Oct 09 2026 13:20 GMT+0800 (China Standard Time)', +}; + +describe('per-turn agent-context trimming', () => { + it('formats the reminder clock to minute precision', () => { + const ms = Date.UTC(2026, 9, 9, 5, 20, 45, 123); + const formatted = formatReminderDate(ms); + expect(formatted).toBe(new Date(ms).toString().replace(':20:45', ':20')); + expect(formatted).not.toMatch(/\d{2}:\d{2}:\d{2}/u); + expect(formatted).toMatch(/2026 \d{2}:\d{2} GMT[+-]\d{4}/u); + }); + + it('keeps the session id in the slim block unless the host says it is in context', () => { + expect(buildSlimAgentContextBlock(env)).toContain('YOUR SESSION ID: sess-123'); + expect(buildSlimAgentContextBlock({ ...env, sessionIdInContext: false })).toContain( + 'YOUR SESSION ID: sess-123', + ); + const trimmed = buildSlimAgentContextBlock({ ...env, sessionIdInContext: true }); + expect(trimmed).not.toContain('sess-123'); + expect(trimmed).toContain('SESSION ROLE: root'); + expect(trimmed).toContain(`date: ${env.date}`); + }); + + it('always keeps the session id in the first-turn full block', () => { + expect(buildAgentContextBlock({ ...env, sessionIdInContext: true })).toContain( + 'YOUR SESSION ID: sess-123', + ); + expect( + agentContextProvider({ env: { ...env, sessionIdInContext: true }, turnCount: 1 } as never), + ).toContain('YOUR SESSION ID: sess-123'); + expect( + agentContextProvider({ env: { ...env, sessionIdInContext: true }, turnCount: 2 } as never), + ).not.toContain('sess-123'); + }); + + it('leaves cloud scene blocks unchanged when the flag is absent', () => { + const cloud = { ...env, scene: 'cloud' as const }; + expect(buildSlimAgentContextBlock(cloud)).toContain('YOUR SESSION ID: sess-123'); + }); +}); diff --git a/packages/shared/src/llm-error-classifier.ts b/packages/shared/src/llm-error-classifier.ts index 3aa02f9f2..2871537f4 100644 --- a/packages/shared/src/llm-error-classifier.ts +++ b/packages/shared/src/llm-error-classifier.ts @@ -62,7 +62,9 @@ export type LLMErrorSignal = | 'empty_response' | 'length' | 'image_limit' - | 'invalid_request'; + | 'invalid_request' + | 'request_too_large' + | 'context_overflow'; /** * Model-side safety classifier decline (Messages API `stop_reason: "refusal"`). Providers surface it @@ -153,6 +155,21 @@ const LLM_IMAGE_LIMIT_MESSAGE_RE = /\btoo many images\b|\b(?:max|maximum) (?:number of |of \d+ )?images\b|\bnumber of images\b[^.]{0,40}\bexceed|\bimages? (?:count |limit )?exceed(?:s|ed)?\b|\bat most \d+ images?\b/i; /** OpenAI/Anthropic-style error type for a request the provider will never accept as sent. */ const LLM_INVALID_REQUEST_MESSAGE_RE = /\binvalid_request_error\b/i; +/** + * The request body is over a provider or client byte limit: HTTP 413 bodies + * (`request_too_large`, "Request Entity Too Large", "Payload Too Large") and the + * local pre-send check (`Request body too large: N bytes exceeds client limit`). + * The same body is rejected again on every resend. + */ +const LLM_REQUEST_TOO_LARGE_MESSAGE_RE = + /\brequest_too_large\b|\bREQUEST_BODY_TOO_LARGE\b|\brequest body too large\b|\brequest entity too large\b|\bpayload too large\b/i; +/** + * The prompt is over the model context window. Kept to unambiguous provider + * shapes; MiniMax reports it as `invalid params, context window exceeds limit + * (2013)`, so the bare 2013 code (a generic invalid-params code) is not used. + */ +const LLM_CONTEXT_OVERFLOW_MESSAGE_RE = + /\bprompt is too long\b|\bcontext window exceeds limit\b|\bexceeds the context window\b|\bmaximum context length\b|\binput is too long for requested model\b|\bcontext[_ ]length[_ ]exceeded\b|\bmodel_context_window_exceeded\b/i; const LLM_USAGE_LIMIT_UPSTREAM_STATUS_CODES = [2056, 2067] as const; const LLM_USAGE_LIMIT_UPSTREAM_STATUS_CODE_SET = new Set( LLM_USAGE_LIMIT_UPSTREAM_STATUS_CODES, @@ -201,6 +218,8 @@ export function normalizeLLMError(input: LLMErrorInput): NormalizedLLMError { const boundedVisible = sanitizeMessage(visibleMessage); if (LLM_IMAGE_LIMIT_MESSAGE_RE.test(boundedVisible)) signals.add('image_limit'); if (LLM_INVALID_REQUEST_MESSAGE_RE.test(boundedVisible)) signals.add('invalid_request'); + if (LLM_REQUEST_TOO_LARGE_MESSAGE_RE.test(boundedVisible)) signals.add('request_too_large'); + if (LLM_CONTEXT_OVERFLOW_MESSAGE_RE.test(boundedVisible)) signals.add('context_overflow'); } applyNumericSignals(facts, signals); return { facts, ...(message ? { sanitizedMessage: message } : {}) }; @@ -252,9 +271,30 @@ export function isLLMDeterministicRequestRejection(normalized: NormalizedLLMErro if (facts.signals.has('image_limit')) { return status === undefined || status === 400 || status === 413 || status === 422; } + if (isLLMRequestOversized(normalized)) return true; return status === 400 && facts.signals.has('invalid_request'); } +/** + * The request is too big to be accepted as sent: an HTTP 413, a byte-limit + * rejection (`request_too_large`, the local pre-send body check) or a context + * window overflow. Resending the same request fails again, so it must not be + * retried. + * Anything that also looks transient (timeout, network, 408/429/5xx) is excluded. + */ +function isLLMRequestOversized(normalized: NormalizedLLMError): boolean { + const { facts } = normalized; + if (facts.explicitAbort || facts.timeout || facts.signals.has('network')) return false; + if (facts.signals.has('tpm_rate_limit')) return false; + const status = effectiveHttpStatus(facts); + if (status !== undefined && (status === 408 || status === 429 || status >= 500)) return false; + if (status === 413) return true; + if (!facts.signals.has('request_too_large') && !facts.signals.has('context_overflow')) { + return false; + } + return status === undefined || status === 400 || status === 413 || status === 422; +} + export function toLLMMetricErrorKind(facts: LLMErrorFacts): LLMMetricErrorKind { try { if (facts.explicitAbort) return 'abort'; @@ -554,6 +594,8 @@ function applyMessageSignals( if (/\bempty.?response/i.test(message)) signals.add('empty_response'); if (LLM_IMAGE_LIMIT_MESSAGE_RE.test(message)) signals.add('image_limit'); if (LLM_INVALID_REQUEST_MESSAGE_RE.test(message)) signals.add('invalid_request'); + if (LLM_REQUEST_TOO_LARGE_MESSAGE_RE.test(message)) signals.add('request_too_large'); + if (LLM_CONTEXT_OVERFLOW_MESSAGE_RE.test(message)) signals.add('context_overflow'); const hasStructuredError = facts.existingProtocolCode !== undefined || effectiveHttpStatus(facts) !== undefined; if (!hasStructuredError && /\b(content.?filter|content.?policy|safety|blocked)\b/i.test(message)) diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index 0b9165fa2..e4edb706a 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -272,7 +272,7 @@ The `build-mode` line is a stable automation protocol, for example for systems t - **`state`**: one of `ready`, `run`, `perm`, `ask`, `plan`, `done`, `fail`, `cancel`, `error`. Publish `ready` only when SSE is live and a structured snapshot confirms no active / retiring / queued turn or pending interaction. Startup, loading, reconnecting, retrying, compaction, stopping, and interaction submission project to `run`. - **Waiting for input**: `perm`, `ask`, and `plan` are stable actionable states and require both `turn` and `request`. Submission immediately returns to `run` and clears `request`; no transient `_submit` state is published. - **Terminal states**: `done`, `fail`, and `cancel` are canonical outcomes of a specific turn and require a non-`none` `turn`. Runtime `blocked` maps to `fail`; `ExecResultV1.status` retains the exact `blocked` result. -- **Fatal errors**: `error` always uses `turn=none request=none` for errors not attributable to the current turn, such as login blocks or invalid interactions. Consumers should fail immediately. It persists until session reset or process recovery. Recoverable reconnect / history-refresh failures and turn errors without canonical settlement remain `run`. +- **Fatal errors**: `error` always uses `turn=none request=none` for errors not attributable to the current turn, such as login blocks or invalid interactions. Consumers should fail immediately. It persists until session reset or process recovery, except that a login block clears once the account is signed in within the same session. Recoverable reconnect / history-refresh failures and turn errors without canonical settlement remain `run`. - **Single source of truth**: Automation Status Store uses only structured runtime / controller facts. It does not read human-facing activity phases, assistant text, or transcripts, or infer completion from spinners / idle labels. - **Read-only status line**: consumers must not infer interaction states from pane content, popup copy, or transcripts. diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index a510365d0..bb026e2c5 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -30,6 +30,8 @@ import { export type TuiRunRuntime = TuiConversationPort & Partial>; +const MAX_TIMER_DELAY_MS = 2_147_483_647; + export interface TuiRunRequest { turnId: string; session: Promise; @@ -181,7 +183,7 @@ export class TuiRunCoordinator { const activeRun = createActiveRun(request.turnId); this.activeRun = activeRun; const startedAtMs = this.nowMs(); - let timeout: ReturnType | undefined; + let timeout: TimeoutChunks | undefined; let session: TuiSession | undefined; let accepted = false; let status: TuiTurnRunStatus = 'succeeded'; @@ -204,10 +206,12 @@ export class TuiRunCoordinator { let executionDeadlineAtMs: number | undefined; if (request.policy?.timeoutMs !== undefined) { executionDeadlineAtMs = this.nowMs() + request.policy.timeoutMs; - timeout = setTimeout(() => { - void this.stopActive(activeRun, 'timeout').catch(() => undefined); - }, request.policy.timeoutMs); - timeout.unref?.(); + timeout = scheduleTimeoutChunks( + () => { + void this.stopActive(activeRun, 'timeout').catch(() => undefined); + }, + request.policy.timeoutMs, + ); } const source = this.sendWithQueueRecovery( @@ -329,7 +333,7 @@ export class TuiRunCoordinator { failure = execError(error); } } finally { - if (timeout) clearTimeout(timeout); + timeout?.clear(); if (this.activeRun === activeRun) this.activeRun = undefined; } @@ -625,6 +629,37 @@ async function waitForSession( type BoundedSettlement = { settled: true; value: T } | { settled: false }; +interface TimeoutChunks { + clear(): void; +} + +/** Node clamps setTimeout delays above 2^31 - 1 ms to 1 ms, so longer + * timeouts are scheduled as consecutive bounded chunks that preserve the + * requested total delay. */ +function scheduleTimeoutChunks(onExpired: () => void, delayMs: number): TimeoutChunks { + let remainingMs = delayMs; + let timer: ReturnType | undefined; + let cleared = false; + const scheduleNext = () => { + if (cleared) return; + if (remainingMs <= 0) { + onExpired(); + return; + } + const chunkMs = Math.min(remainingMs, MAX_TIMER_DELAY_MS); + remainingMs -= chunkMs; + timer = setTimeout(scheduleNext, chunkMs); + timer.unref?.(); + }; + scheduleNext(); + return { + clear() { + cleared = true; + if (timer) clearTimeout(timer); + }, + }; +} + async function settleWithin(task: Promise, timeoutMs: number): Promise> { if (timeoutMs <= 0) return { settled: false }; let timer: ReturnType | undefined; diff --git a/packages/tui/src/cli/main.ts b/packages/tui/src/cli/main.ts index 2552c9f7e..3fb9e5ac2 100644 --- a/packages/tui/src/cli/main.ts +++ b/packages/tui/src/cli/main.ts @@ -98,6 +98,7 @@ export async function runTuiCli(dependencies: RunTuiCliDependencies = {}): Promi try { (dependencies.configureNetworkProxy ?? configureTuiNetworkProxy)({ environment: processRef.env, + writeWarning: (message) => processRef.stderr.write(`${message}\n`), }); const createProgram = dependencies.createProgram ?? createTuiProgram; await createProgram({ diff --git a/packages/tui/src/cli/network-proxy.ts b/packages/tui/src/cli/network-proxy.ts index 528c914b1..3056f3928 100644 --- a/packages/tui/src/cli/network-proxy.ts +++ b/packages/tui/src/cli/network-proxy.ts @@ -1,13 +1,40 @@ import * as undici from 'undici'; -import { Agent, Dispatcher, ProxyAgent } from 'undici'; +import { Agent, buildConnector, Dispatcher, ProxyAgent, Socks5ProxyAgent } from 'undici'; const LOOPBACK_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] as const; +const HTTP_PROXY_PROTOCOLS: ReadonlySet = new Set(['http:', 'https:']); +// undici's Socks5ProxyAgent accepts `socks5:` and its alias `socks:`. It always sends +// host names to the proxy for resolution, which is what `socks5h:` requests, so that +// scheme is accepted too and rewritten before the agent is constructed. +const SOCKS5_PROXY_PROTOCOLS: ReadonlySet = new Set(['socks5:', 'socks5h:', 'socks:']); +const SUPPORTED_PROXY_SCHEMES = 'http://, https://, socks5://, or socks5h://'; +const SOCKS5_EXPERIMENTAL_WARNING_MESSAGE = + 'SOCKS5 proxy support is experimental and subject to change'; +const SOCKS5_EXPERIMENTAL_WARNING_TYPE = 'ExperimentalWarning'; +// Matches undici's default SOCKS5 connect timeout, which a custom connector replaces. +const SOCKS5_PROXY_CONNECT_TIMEOUT_MS = 5_000; +const IGNORED_ALL_PROXY_ADVICE = + "Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port, or set ALL_PROXY to a socks5:// or socks5h:// URL."; interface ProxyValue { readonly name: string; readonly value: string; } +type ProxyValueClassification = + | { readonly kind: 'http' | 'socks5' } + | { + readonly kind: 'unsupported'; + readonly reason: 'invalid-url' | 'protocol' | 'credentials'; + readonly protocol?: string; + readonly redacted?: string; + }; + +interface ProxyCredentials { + readonly username: string; + readonly password: string; +} + export type TuiProxyConfiguration = | { readonly mode: 'direct' } | { @@ -17,6 +44,11 @@ export type TuiProxyConfiguration = readonly noProxy: string; }; +export interface ResolveTuiProxyConfigurationOptions { + /** Receives one line per ignored proxy setting. Omit to discard warnings. */ + readonly writeWarning?: (message: string) => void; +} + export interface ConfigureTuiNetworkProxyOptions { readonly environment?: NodeJS.ProcessEnv; readonly createProxyDispatcher?: (options: { @@ -26,12 +58,22 @@ export interface ConfigureTuiNetworkProxyOptions { }) => Dispatcher; readonly setGlobalDispatcher?: (dispatcher: Dispatcher) => void; readonly installFetch?: () => void; + /** Defaults to writing a line to process.stderr. */ + readonly writeWarning?: (message: string) => void; +} + +interface EmitWarningTarget { + emitWarning: typeof process.emitWarning; } export function resolveTuiProxyConfiguration( environment: NodeJS.ProcessEnv = process.env, + options: ResolveTuiProxyConfigurationOptions = {}, ): TuiProxyConfiguration { - const allProxy = firstProxyValue(environment, 'ALL_PROXY', 'all_proxy'); + const allProxy = usableAllProxyValue( + firstProxyValue(environment, 'ALL_PROXY', 'all_proxy'), + options.writeWarning, + ); const httpProxy = firstProxyValue(environment, 'HTTP_PROXY', 'http_proxy') ?? allProxy; const httpsProxy = firstProxyValue(environment, 'HTTPS_PROXY', 'https_proxy') ?? allProxy ?? httpProxy; @@ -51,7 +93,9 @@ export function resolveTuiProxyConfiguration( export function configureTuiNetworkProxy( options: ConfigureTuiNetworkProxyOptions = {}, ): TuiProxyConfiguration { - const configuration = resolveTuiProxyConfiguration(options.environment); + const configuration = resolveTuiProxyConfiguration(options.environment, { + writeWarning: options.writeWarning ?? writeStderrWarning, + }); if (configuration.mode === 'direct') return configuration; const createProxyDispatcher = @@ -69,12 +113,73 @@ export function configureTuiNetworkProxy( export function createTuiNetworkDispatcher( environment: NodeJS.ProcessEnv = process.env, ): Dispatcher { + // Ignored ALL_PROXY values were already reported once at CLI startup by + // configureTuiNetworkProxy; repeating the warning here would interleave with the TUI. const configuration = resolveTuiProxyConfiguration(environment); return configuration.mode === 'proxy' ? new TuiProxyDispatcher(configuration) : new Agent({ allowH2: false }); } +/** + * Masks the user name and password of a proxy URL so it can appear in + * diagnostics. Returns undefined when the value is not a parseable URL with a host, + * because an unparseable value cannot be redacted reliably. + */ +export function redactProxyUrl(value: string): string | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if (!url.host) return undefined; + const credentials = url.username || url.password ? `${url.password ? '***:***' : '***'}@` : ''; + return `${url.protocol}//${credentials}${url.host}`; +} + +/** + * Runs `create` while dropping only undici's one-time SOCKS5 experimental + * warning. Every other warning, including other ExperimentalWarnings, is + * forwarded unchanged, and the original emitter is restored afterwards. + */ +export function withoutSocks5ExperimentalWarning( + create: () => T, + target: EmitWarningTarget = process, +): T { + const original = target.emitWarning; + const filtered = function emitWarning(this: unknown, ...args: unknown[]): void { + if (isSocks5ExperimentalWarning(args)) return; + Reflect.apply(original, this, args); + }; + target.emitWarning = filtered as typeof process.emitWarning; + try { + return create(); + } finally { + target.emitWarning = original; + } +} + +function isSocks5ExperimentalWarning(args: readonly unknown[]): boolean { + const [warning, typeOrOptions] = args; + const message = + typeof warning === 'string' ? warning : warning instanceof Error ? warning.message : undefined; + if (message !== SOCKS5_EXPERIMENTAL_WARNING_MESSAGE) return false; + const type = + typeof typeOrOptions === 'string' + ? typeOrOptions + : typeof typeOrOptions === 'object' && typeOrOptions !== null + ? (typeOrOptions as { type?: unknown }).type + : warning instanceof Error + ? warning.name + : undefined; + return type === SOCKS5_EXPERIMENTAL_WARNING_TYPE; +} + +function writeStderrWarning(message: string): void { + process.stderr.write(`${message}\n`); +} + function firstProxyValue( environment: NodeJS.ProcessEnv, ...names: readonly string[] @@ -86,17 +191,93 @@ function firstProxyValue( return undefined; } -function validateProxyValue(proxy: ProxyValue | undefined): void { - if (!proxy) return; +function classifyProxyValue(value: string): ProxyValueClassification { let url: URL; try { - url = new URL(proxy.value); + url = new URL(value); + } catch { + return { kind: 'unsupported', reason: 'invalid-url' }; + } + // Values such as `localhost:7890` parse with `localhost:` as the scheme. + if (!url.hostname) return { kind: 'unsupported', reason: 'invalid-url' }; + const kind = HTTP_PROXY_PROTOCOLS.has(url.protocol) + ? 'http' + : SOCKS5_PROXY_PROTOCOLS.has(url.protocol) + ? 'socks5' + : undefined; + if (!kind) { + return { + kind: 'unsupported', + reason: 'protocol', + protocol: url.protocol, + redacted: redactProxyUrl(value), + }; + } + // Both undici proxy agents percent-decode URL credentials while being + // constructed; a stray `%` would otherwise throw URIError at startup. + if (!decodeProxyCredentials(url)) { + return { kind: 'unsupported', reason: 'credentials', redacted: redactProxyUrl(value) }; + } + return { kind }; +} + +function decodeProxyCredentials(url: URL): ProxyCredentials | undefined { + try { + return { + username: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }; } catch { - throw new Error(`${proxy.name} must be an http:// or https:// URL.`); + return undefined; } - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) { - throw new Error(`${proxy.name} must be an http:// or https:// URL.`); +} + +/** + * ALL_PROXY is only a fallback, and SOCKS tools commonly export it next to an + * explicit HTTP(S)_PROXY. A value this client cannot use must not prevent + * startup, so it is reported and skipped instead of rejected. + */ +function usableAllProxyValue( + proxy: ProxyValue | undefined, + writeWarning: ((message: string) => void) | undefined, +): ProxyValue | undefined { + if (!proxy) return undefined; + const classification = classifyProxyValue(proxy.value); + if (classification.kind !== 'unsupported') return proxy; + writeWarning?.( + `Warning: ignoring ${describeProxySetting(proxy.name, classification)} because ${describeUnsupportedReason(classification)}; it will not be used. ${IGNORED_ALL_PROXY_ADVICE}`, + ); + return undefined; +} + +function validateProxyValue(proxy: ProxyValue | undefined): void { + if (!proxy) return; + const classification = classifyProxyValue(proxy.value); + if (classification.kind !== 'unsupported') return; + if (classification.reason === 'credentials') { + throw new Error( + `${describeProxySetting(proxy.name, classification)} has a user name or password that is not valid percent-encoding. Encode reserved characters, for example % as %25.`, + ); } + throw new Error(`${proxy.name} must be an ${SUPPORTED_PROXY_SCHEMES} URL.`); +} + +function describeProxySetting( + name: string, + classification: { readonly redacted?: string }, +): string { + return classification.redacted ? `${name}=${classification.redacted}` : name; +} + +function describeUnsupportedReason(classification: { + readonly reason: 'invalid-url' | 'protocol' | 'credentials'; + readonly protocol?: string; +}): string { + if (classification.reason === 'credentials') { + return 'its user name or password is not valid percent-encoding (encode % as %25)'; + } + if (!classification.protocol) return 'it is not a valid proxy URL'; + return `${classification.protocol}// proxies are not supported (use ${SUPPORTED_PROXY_SCHEMES})`; } function withLoopbackNoProxy(existing: string | undefined): string { @@ -115,7 +296,9 @@ function installUndiciFetch(): void { (undici as typeof undici & { install?: () => void }).install?.(); } -function createTuiProxyAgent(uri: string): ProxyAgent { +function createTuiProxyAgent(uri: string): Dispatcher { + const url = new URL(uri); + if (SOCKS5_PROXY_PROTOCOLS.has(url.protocol)) return createTuiSocks5ProxyAgent(url); // Preserve HTTP/1.1 on both TLS hops and CONNECT for plain HTTP targets. return new ProxyAgent({ uri, @@ -125,6 +308,45 @@ function createTuiProxyAgent(uri: string): ProxyAgent { }); } +function createTuiSocks5ProxyAgent(url: URL): Dispatcher { + const credentials = decodeProxyCredentials(url); + if (!credentials) throw new Error('Proxy user name or password is not valid percent-encoding.'); + // Pass decoded credentials as options and drop them from the URL so undici + // never decodes them again. An empty password stays unset, as with undici. + const proxyUrl = new URL(url.href); + proxyUrl.username = ''; + proxyUrl.password = ''; + // Socks5ProxyAgent rejects `socks5h:` although it already resolves names remotely. + if (proxyUrl.protocol === 'socks5h:') proxyUrl.protocol = 'socks5:'; + const options: Socks5ProxyAgent.Options = { + connectTimeout: SOCKS5_PROXY_CONNECT_TIMEOUT_MS, + connect: withUnbracketedProxyHost(buildConnector({ timeout: SOCKS5_PROXY_CONNECT_TIMEOUT_MS })), + ...(credentials.username ? { username: credentials.username } : {}), + ...(credentials.password ? { password: credentials.password } : {}), + }; + return withoutSocks5ExperimentalWarning(() => new Socks5ProxyAgent(proxyUrl, options)); +} + +/** + * Socks5ProxyAgent dials `URL.hostname`, which keeps the brackets of an IPv6 + * literal (`[::1]`) and makes the socket connect fail with ENOTFOUND. + */ +function withUnbracketedProxyHost(connector: buildConnector.connector): buildConnector.connector { + return (options, callback) => + connector( + { + ...options, + hostname: stripIpv6Brackets(options.hostname), + ...(options.host === undefined ? {} : { host: stripIpv6Brackets(options.host) }), + }, + callback, + ); +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + class TuiProxyDispatcher extends Dispatcher { readonly #directDispatcher = new Agent({ allowH2: false }); readonly #httpDispatcher: Dispatcher; diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index e9190c7c3..609949012 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -53,6 +53,7 @@ import { import { detectProcessTerminalCapabilities } from './platform/terminal-capabilities.js'; import { createTuiTextClipboardWriter } from './platform/terminal-clipboard.js'; import { TuiTerminalNotifications } from './platform/terminal-notifications.js'; +import { TuiTerminalProgramStatus } from './platform/terminal-program-status.js'; import { ProcessTerminal, type Component, @@ -170,6 +171,11 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { }, { settings: options.notifications }, ); + const programStatusCapabilities = options.terminalCapabilities ?? capabilities; + const terminalProgramStatus = new TuiTerminalProgramStatus( + terminal, + programStatusCapabilities.isTTY && options.programStatus !== false, + ); themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, @@ -183,6 +189,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { renderer, tui, terminalNotifications, + terminalProgramStatus, themeController, openExternalTarget, writeClipboardText, diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 2d130b5dd..0a639dfcc 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -72,6 +72,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { renderer, tui, terminalNotifications, + terminalProgramStatus, themeController, openExternalTarget, writeClipboardText, @@ -300,7 +301,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { terminalNotifications.notifyOnce(kind, key); }, ...delegationFlow.permissionResolvers(liveRunId), - agentStatusLineItems: options.statusLineItems, stopTurn: () => { void abortLiveTurn(); }, @@ -828,6 +828,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { isStarted: () => started, isStopped: () => stopped, setTerminalTitle: (title) => terminal.setTitle(title), + setProgramStatus: (programStatus) => terminalProgramStatus.update(programStatus), connection: () => stateStore.snapshot().connection, liveRunId, runProjection: () => runProjection.snapshot(), @@ -882,6 +883,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (stopped) return stoppedPromise; stateStore.dispatch({ type: 'lifecycle/leaveUi' }); stopped = true; + terminalProgramStatus.dispose(); const bashStopped = bashFlow.stop(); detachInputFlow(); composerDraft.abortClipboardRead(); @@ -930,6 +932,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { async function suspend(): Promise { if (!started || stopped || suspended) return; suspended = true; + terminalProgramStatus.setActive(false); renderer.stop(); await terminal.drainOutput?.(); await draftLifecycle?.suspend(); @@ -938,6 +941,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (!started || stopped || !suspended) return; suspended = false; renderer.start(); + terminalProgramStatus.setActive(true); + updateChrome(controller.snapshot()); runtimeEventFlow.restart(); tui.requestRender(true); draftLifecycle?.resume(); @@ -957,6 +962,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { start() { if (started || stopped) return; started = true; + terminalProgramStatus.setActive(true); updateChrome(controller.snapshot()); surfaceHost.setChatFocus(editor); runtimeEventFlow.start(); diff --git a/packages/tui/src/tui/automation/status-store.ts b/packages/tui/src/tui/automation/status-store.ts index 777a73d15..9a93d1861 100644 --- a/packages/tui/src/tui/automation/status-store.ts +++ b/packages/tui/src/tui/automation/status-store.ts @@ -23,6 +23,12 @@ export interface TuiAutomationStatusFacts { export interface TuiAutomationStatusSnapshot { readonly seq: string; readonly status: TuiAgentStatus; + /** + * `error` caused by a missing login rather than a fatal failure. The `[V]` + * line still reports `error`; human-facing outputs can say that login is + * needed. Derived from current account facts, so it clears after login. + */ + readonly loginRequired?: true; readonly sessionId?: string; readonly turnId?: string; readonly requestId?: string; @@ -82,8 +88,8 @@ export class TuiAutomationStatusStore { acceptedSettledTurn?.status === 'failed' || acceptedSettledTurn?.status === 'blocked'; if ( facts.interaction?.kind === 'invalid' || - (facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) || (facts.snapshot.status === 'error' && + !(facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) && facts.snapshot.errorRetryable === false && !activeTurnId && !this.pendingTurnId && @@ -139,6 +145,11 @@ export class TuiAutomationStatusStore { return { status: 'error', ...session, ...counts }; } + // Login is recoverable within the same Session; derive it from current account facts. + if (facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) { + return { status: 'error', loginRequired: true, ...session, ...counts }; + } + if (facts.snapshot.status === 'error') { const failedTurnId = facts.settledTurn?.status === 'failed' || facts.settledTurn?.status === 'blocked' diff --git a/packages/tui/src/tui/controller/interaction/interaction-flow.ts b/packages/tui/src/tui/controller/interaction/interaction-flow.ts index d5f2aa8ad..1a9b0fc81 100644 --- a/packages/tui/src/tui/controller/interaction/interaction-flow.ts +++ b/packages/tui/src/tui/controller/interaction/interaction-flow.ts @@ -47,7 +47,6 @@ import { TuiPlanEntryPanel, type TuiPlanEntryAction, } from '../../features/interaction/plan-entry-panel.js'; -import { parseTuiStatusLineItems } from '../../shell/status-line-items.js'; type AppendLocalCell = (content: string, kind?: 'final-summary' | 'warning' | 'error') => void; @@ -81,14 +80,12 @@ export interface TuiInteractionFlowOptions { readonly notify?: (kind: TuiTerminalNotificationKind, key: string) => void; readonly permissionSessionScope?: (activeSessionId: string) => ReadonlySet; readonly permissionOwnerTurnId?: (ownerSessionId: string) => string | undefined; - readonly agentStatusLineItems?: readonly string[]; readonly stopTurn: () => void; } export class TuiInteractionFlow { private readonly defaultAgentName: string; private readonly continuation: QuestionnaireContinuation; - private readonly agentStatusEnabled: boolean; private readonly questionnaireDismissals = new Set(); private panel: Component | undefined; private planViewRevision = 0; @@ -97,10 +94,6 @@ export class TuiInteractionFlow { constructor(private readonly options: TuiInteractionFlowOptions) { this.defaultAgentName = options.defaultAgentName ?? KCODE_DEFAULT_AGENT_NAME; - this.agentStatusEnabled = Boolean( - options.agentStatusLineItems && - parseTuiStatusLineItems(options.agentStatusLineItems).includes('build-mode'), - ); this.continuation = new QuestionnaireContinuation({ runtime: options.runtime, controller: options.controller, @@ -156,9 +149,11 @@ export class TuiInteractionFlow { return false; } - /** Structured interaction state for the machine status line. */ + /** + * Structured interaction state shared by the machine status line, terminal + * title and program status. Display settings must not change these facts. + */ agentReadback(): TuiAgentInteractionReadback | undefined { - if (!this.agentStatusEnabled) return undefined; const permission = this.permission(); if (permission) { if (!hasPermissionRequestId(permission)) return { kind: 'invalid' }; @@ -1301,7 +1296,6 @@ export class TuiInteractionFlow { } private withAgentSubmission(requestId: string, action: () => Promise): Promise { - if (!this.agentStatusEnabled) return action(); const sessionId = this.options.stateStore.snapshot().activeSessionId; this.submittingRequestId = requestId; this.submittingSessionId = sessionId; diff --git a/packages/tui/src/tui/controller/product/chrome-flow.ts b/packages/tui/src/tui/controller/product/chrome-flow.ts index 6d8ea4048..f7ee62c76 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -27,6 +27,7 @@ import { type TuiCommand, } from '../../commands/catalog.js'; import { resolveTuiComposerInputIntent } from '../../commands/input-intent.js'; +import type { TuiProgramStatus } from '../../platform/terminal-program-status.js'; type PresentationSink = { setState(state: TuiVisiblePresentation[K]): void; @@ -52,6 +53,7 @@ export class TuiChromeFlow { readonly isStarted: () => boolean; readonly isStopped: () => boolean; readonly setTerminalTitle: (title: string) => void; + readonly setProgramStatus: (status: TuiProgramStatus) => void; readonly connection: () => Pick; /** Multi-Session state kernel, including the background parent Turn. */ readonly liveRunId: (snapshot: TuiChatSnapshot) => string | undefined; @@ -218,6 +220,15 @@ export class TuiChromeFlow { ...(agentCounts ? { agentCounts } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), }); + if (this.options.isStarted()) { + // Program status follows the machine projection. Login is a recoverable + // error on the `[V]` line and a distinct auth wait for the terminal. + // The fork terminal title stays the session name or "Kinetick Code". + const programStatus: TuiProgramStatus = automationStatus.loginRequired + ? 'auth' + : automationStatus.status; + this.options.setProgramStatus(programStatus); + } const shell = { ...presentation.shell, agentSeq: automationStatus.seq, diff --git a/packages/tui/src/tui/controller/projection/turn-output-rate.ts b/packages/tui/src/tui/controller/projection/turn-output-rate.ts index 35df76bd6..40cb5e2dd 100644 --- a/packages/tui/src/tui/controller/projection/turn-output-rate.ts +++ b/packages/tui/src/tui/controller/projection/turn-output-rate.ts @@ -29,6 +29,14 @@ export class TuiTurnOutputRate { const decodeDurationMs = event.message.usage?.decodeDurationMs; // Missing timing is not a zero-duration sample and must not use the full request duration. if (!nonnegativeFinite(outputTokens) || !nonnegativeFinite(decodeDurationMs)) return this.value; + if ( + bufferedBurst( + outputTokens, + decodeDurationMs, + event.message.usage?.requestDurationMs, + ) + ) + return this.value; this.samples.set(event.message.id, { outputTokens, decodeDurationMs }); let tokens = 0; let duration = 0; @@ -60,6 +68,20 @@ export class TuiTurnOutputRate { } } +/** Buffered upstream output arrives in one burst; its decode window says nothing about model speed. */ +const BURST_MIN_TOKENS_PER_SECOND = 1_000; +const BURST_MAX_DECODE_SHARE = 0.1; + +function bufferedBurst( + tokens: number, + decodeMs: number, + requestMs: number | undefined, +): boolean { + if (!nonnegativeFinite(requestMs) || decodeMs >= requestMs * BURST_MAX_DECODE_SHARE) + return false; + return tokens > (decodeMs / 1_000) * BURST_MIN_TOKENS_PER_SECOND; +} + function nonnegativeFinite(value: number | undefined): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0; } diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index a0a76f1f1..d6b4fdbc1 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -343,6 +343,9 @@ export async function launchTui( ...(presentationConfig.notifications ? { notifications: presentationConfig.notifications } : {}), + ...(presentationConfig.programStatus === undefined + ? {} + : { programStatus: presentationConfig.programStatus }), ...(process.env[KCODE_TUI_RESULT_PATH_ENV] ? { automationResultPath: process.env[KCODE_TUI_RESULT_PATH_ENV] } : {}), diff --git a/packages/tui/src/tui/platform/terminal-program-status.ts b/packages/tui/src/tui/platform/terminal-program-status.ts new file mode 100644 index 000000000..1f9c7f799 --- /dev/null +++ b/packages/tui/src/tui/platform/terminal-program-status.ts @@ -0,0 +1,65 @@ +import type { Terminal } from '../engine/public.js'; +import type { TuiAgentStatus } from '../shell/status-protocol.js'; + +export type TuiProgramStatus = TuiAgentStatus | 'auth'; + +const REPORTS: Record = { + ready: 'state=idle', + run: 'state=working', + perm: 'state=blocked:kind=permission', + plan: 'state=blocked:kind=permission', + ask: 'state=blocked:kind=question', + auth: 'state=blocked:kind=auth', + done: 'state=done', + fail: 'state=error', + error: 'state=error', + cancel: 'state=idle', +}; + +/** Optional OSC 7501 output for the interactive TUI's root record. */ +export class TuiTerminalProgramStatus { + private active = false; + private disposed = false; + private lastReport?: string; + + constructor( + private readonly terminal: Pick, + private readonly isTTY: boolean, + ) {} + + setActive(active: boolean): void { + if (this.disposed || this.active === active) return; + if (!active) { + if (this.lastReport !== undefined) this.update('ready'); + this.lastReport = undefined; + } + this.active = active; + } + + update(status: TuiProgramStatus): void { + if (!this.active || this.disposed || !this.isTTY) return; + const report = REPORTS[status]; + if (report === undefined || report === this.lastReport) return; + try { + // Detection is optional in OSC 7501 revision 0.4. Unknown OSCs are ignored. + // Only fixed metadata is sent; prompts, output and credentials stay in the TUI. + this.terminal.write(`\u001b]7501;${report}:app=kcode\u001b\\`); + this.lastReport = report; + } catch { + // Optional status output must not interrupt a Turn or interaction. + } + } + + dispose(): void { + // Completed results survive process exit. An interrupted live TUI is idle. + if ( + this.lastReport !== undefined && + this.lastReport !== REPORTS.done && + this.lastReport !== REPORTS.error + ) { + this.update('ready'); + } + this.active = false; + this.disposed = true; + } +} diff --git a/packages/tui/src/tui/platform/terminal-title.ts b/packages/tui/src/tui/platform/terminal-title.ts index 3fe2be5d6..1594b2bc1 100644 --- a/packages/tui/src/tui/platform/terminal-title.ts +++ b/packages/tui/src/tui/platform/terminal-title.ts @@ -1,15 +1,16 @@ import path from 'node:path'; import type { Terminal } from '../engine/public.js'; -import type { TuiAgentStatus } from '../shell/status-protocol.js'; +import type { TuiProgramStatus } from './terminal-program-status.js'; import { sanitizeTerminalLabel } from '../rendering/terminal-text.js'; const DEFAULT_ITEMS = ['status', 'session-name', 'app-name'] as const; -const STATUS_LABELS: Record = { +const STATUS_LABELS: Record = { ready: 'Ready', run: 'Working', perm: 'Needs approval', plan: 'Needs approval', ask: 'Needs input', + auth: 'Needs login', done: 'Done', fail: 'Failed', cancel: 'Stopped', @@ -32,7 +33,7 @@ export function formatTuiTerminalTitle( readonly title?: string; readonly sessionId?: string; readonly workspace: string; - readonly status: TuiAgentStatus; + readonly status: TuiProgramStatus; }, items: readonly string[] | null = DEFAULT_ITEMS, ): string | undefined { diff --git a/packages/tui/src/tui/shell/status-line-config.ts b/packages/tui/src/tui/shell/status-line-config.ts index 6103333fe..b5e8a4a1d 100644 --- a/packages/tui/src/tui/shell/status-line-config.ts +++ b/packages/tui/src/tui/shell/status-line-config.ts @@ -8,6 +8,7 @@ export interface TuiPresentationConfig { readonly showTips?: boolean; readonly statusLineItems?: readonly string[]; readonly notifications?: TuiNotificationSettings; + readonly programStatus?: boolean; readonly customStatusLine?: TuiCustomStatusLineConfig; } @@ -32,6 +33,7 @@ export async function readTuiPresentationConfig(dataDir: string): Promise; diff --git a/packages/tui/test/unit/headless-invocation.test.ts b/packages/tui/test/unit/headless-invocation.test.ts index f0808fdc6..3f804959c 100644 --- a/packages/tui/test/unit/headless-invocation.test.ts +++ b/packages/tui/test/unit/headless-invocation.test.ts @@ -395,6 +395,8 @@ describe('headless invocation', () => { ['30s', 30_000], ['2m', 120_000], ['1H', 3_600_000], + ['600h', 2_160_000_000], + ['2147483648ms', 2_147_483_648], ] as const)('parses timeout %s as %i milliseconds', async (timeout, timeoutMs) => { const cwd = await workspace(); await expect( diff --git a/packages/tui/test/unit/host-tui-settings.test.ts b/packages/tui/test/unit/host-tui-settings.test.ts index 9c80f66e1..da144d5b6 100644 --- a/packages/tui/test/unit/host-tui-settings.test.ts +++ b/packages/tui/test/unit/host-tui-settings.test.ts @@ -43,6 +43,17 @@ describe('terminal presentation configuration', () => { }); }, ); + + it.each([ + ['false', false], + ['true', true], + ['off', undefined], + ] as const)('reads programStatus %s through the config loader', async (raw, expected) => { + const dataDir = await temporaryDataDir(); + await writeFile(join(dataDir, 'config.yaml'), `tui:\n programStatus: ${raw}\n`, 'utf8'); + const settings = await readTuiPresentationConfig(dataDir); + expect(settings.programStatus).toBe(expected); + }); }); describe('TUI mode settings', () => { diff --git a/packages/tui/test/unit/network-proxy-socks5.test.ts b/packages/tui/test/unit/network-proxy-socks5.test.ts new file mode 100644 index 000000000..8f1340ceb --- /dev/null +++ b/packages/tui/test/unit/network-proxy-socks5.test.ts @@ -0,0 +1,439 @@ +import { once } from 'node:events'; +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; +import { + type AddressInfo, + connect, + createServer as createNetServer, + type Server, + type Socket, +} from 'node:net'; +import { fetch, type Dispatcher } from 'undici'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + configureTuiNetworkProxy, + createTuiNetworkDispatcher, +} from '../../src/cli/network-proxy.js'; + +/** + * End-to-end coverage for SOCKS5 proxies (#393) against loopback-only fixtures. + * + * The proxied requests target `*.socks.invalid` host names. They cannot resolve + * locally, so undici must hand the name to the SOCKS5 server, which maps every + * requested domain to the local HTTP fixture. A successful response therefore + * proves that the request went through the proxy, and the server records the + * CONNECT address type to show that the name, not a local IP, was sent. Loopback hosts are always in + * the TUI's NO_PROXY list, so a request to 127.0.0.1 is the NO_PROXY case: it + * must reach the HTTP fixture directly while the SOCKS5 server sees nothing. + */ + +interface SocksConnectRecord { + /** RFC 1928 ATYP of the CONNECT request: 0x01 IPv4, 0x03 domain name, 0x04 IPv6. */ + readonly addressType: number; + readonly host: string; + readonly port: number; + readonly username?: string; +} + +interface SocksFixture { + readonly port: number; + readonly connects: SocksConnectRecord[]; + readonly connections: { count: number }; + close(): Promise; +} + +const SOCKS_VERSION = 0x05; +const NO_AUTH = 0x00; +const USERNAME_PASSWORD = 0x02; +const NO_ACCEPTABLE_METHOD = 0xff; +const ATYP_DOMAIN_NAME = 0x03; + +let target: HttpServer; +let targetPort: number; +const targetRequests: string[] = []; +const openDispatchers: Dispatcher[] = []; +const openFixtures: SocksFixture[] = []; + +/** Some CI containers disable IPv6; the IPv6 proxy case is skipped there. */ +async function canListenOnIpv6Loopback(): Promise { + const probe = createNetServer(); + try { + probe.listen(0, '::1'); + await once(probe, 'listening'); + probe.close(); + await once(probe, 'close'); + return true; + } catch { + return false; + } +} + +const HAS_IPV6_LOOPBACK = await canListenOnIpv6Loopback(); + +function createReader(socket: Socket) { + let buffered = Buffer.alloc(0); + let pending: { length: number; resolve: (value: Buffer) => void } | undefined; + const flush = () => { + if (!pending || buffered.length < pending.length) return; + const chunk = buffered.subarray(0, pending.length); + buffered = buffered.subarray(pending.length); + const { resolve } = pending; + pending = undefined; + resolve(chunk); + }; + const onData = (data: Buffer) => { + buffered = Buffer.concat([buffered, data]); + flush(); + }; + socket.on('data', onData); + return { + read: (length: number) => + new Promise((resolve) => { + pending = { length, resolve }; + flush(); + }), + /** Stops parsing and returns bytes received after the handshake. */ + detach: () => { + socket.off('data', onData); + return buffered; + }, + }; +} + +/** Minimal RFC 1928 server: CONNECT only, with optional RFC 1929 authentication. */ +async function startSocksServer( + credentials?: { username: string; password: string }, + listenHost = '127.0.0.1', +): Promise { + const connects: SocksConnectRecord[] = []; + const connections = { count: 0 }; + const sockets = new Set(); + const server: Server = createNetServer((client) => { + connections.count += 1; + sockets.add(client); + client.on('close', () => sockets.delete(client)); + client.on('error', () => undefined); + void handleClient(client).catch(() => client.destroy()); + }); + + async function handleClient(client: Socket): Promise { + const reader = createReader(client); + const { read } = reader; + const [version, methodCount] = await read(2); + if (version !== SOCKS_VERSION) throw new Error('Unexpected SOCKS version'); + const methods = [...(await read(methodCount ?? 0))]; + const method = credentials ? USERNAME_PASSWORD : NO_AUTH; + if (!methods.includes(method)) { + client.end(Buffer.from([SOCKS_VERSION, NO_ACCEPTABLE_METHOD])); + return; + } + client.write(Buffer.from([SOCKS_VERSION, method])); + + let username: string | undefined; + if (credentials) { + const [, usernameLength] = await read(2); + username = (await read(usernameLength ?? 0)).toString('utf8'); + const [passwordLength] = await read(1); + const password = (await read(passwordLength ?? 0)).toString('utf8'); + const accepted = username === credentials.username && password === credentials.password; + client.write(Buffer.from([0x01, accepted ? 0x00 : 0x01])); + if (!accepted) { + client.end(); + return; + } + } + + const [, command, , addressType = 0] = await read(4); + let host: string; + if (addressType === 0x01) host = [...(await read(4))].join('.'); + else if (addressType === 0x03) { + const [length] = await read(1); + host = (await read(length ?? 0)).toString('utf8'); + } else if (addressType === 0x04) { + host = (await read(16)).toString('hex').replace(/(.{4})(?!$)/gu, '$1:'); + } else throw new Error('Unsupported address type'); + const port = (await read(2)).readUInt16BE(0); + if (command !== 0x01) throw new Error('Only CONNECT is supported'); + connects.push({ addressType, host, port, ...(username ? { username } : {}) }); + + // Every requested destination is served by the local HTTP fixture. + const upstream = connect(targetPort, '127.0.0.1'); + sockets.add(upstream); + upstream.on('close', () => sockets.delete(upstream)); + upstream.on('error', () => client.destroy()); + await once(upstream, 'connect'); + const early = reader.detach(); + if (early.length > 0) upstream.write(early); + client.write(Buffer.from([SOCKS_VERSION, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])); + client.pipe(upstream); + upstream.pipe(client); + } + + server.listen(0, listenHost); + await once(server, 'listening'); + const fixture: SocksFixture = { + port: (server.address() as AddressInfo).port, + connects, + connections, + close: async () => { + for (const socket of sockets) socket.destroy(); + server.close(); + await once(server, 'close'); + }, + }; + openFixtures.push(fixture); + return fixture; +} + +function track(dispatcher: T): T { + openDispatchers.push(dispatcher); + return dispatcher; +} + +beforeAll(async () => { + target = createHttpServer((request, response) => { + targetRequests.push(`${request.headers.host ?? ''}${request.url ?? ''}`); + response.end(`hello from ${request.headers.host ?? 'unknown'}`); + }); + target.listen(0, '127.0.0.1'); + await once(target, 'listening'); + targetPort = (target.address() as AddressInfo).port; +}); + +afterEach(async () => { + await Promise.all(openDispatchers.splice(0).map((dispatcher) => dispatcher.destroy())); + await Promise.all(openFixtures.splice(0).map((fixture) => fixture.close())); + targetRequests.length = 0; +}); + +afterAll(async () => { + target.close(); + await once(target, 'close'); +}); + +describe('SOCKS5 proxy dispatch', () => { + it('routes requests through ALL_PROXY=socks5:// without an experimental warning', async () => { + const socks = await startSocksServer(); + const warnings: string[] = []; + const onWarning = (warning: Error) => warnings.push(warning.message); + process.on('warning', onWarning); + let dispatcher: Dispatcher; + try { + dispatcher = track( + createTuiNetworkDispatcher({ + all_proxy: ` socks5://127.0.0.1:${socks.port} `, + }), + ); + const response = await fetch('http://target.socks.invalid/through-socks', { dispatcher }); + expect(response.status).toBe(200); + expect(await response.text()).toBe('hello from target.socks.invalid'); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('warning', onWarning); + } + + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'target.socks.invalid', port: 80 }, + ]); + expect(targetRequests).toEqual(['target.socks.invalid/through-socks']); + expect(warnings.filter((message) => message.includes('SOCKS5'))).toEqual([]); + }); + + it('authenticates with credentials from the proxy URL', async () => { + const socks = await startSocksServer({ + username: 'mcode user', + password: 'p@ss:word', + }); + const dispatcher = track( + createTuiNetworkDispatcher({ + HTTPS_PROXY: `socks5://mcode%20user:p%40ss%3Aword@127.0.0.1:${socks.port}`, + HTTP_PROXY: `socks5://mcode%20user:p%40ss%3Aword@127.0.0.1:${socks.port}`, + }), + ); + + const response = await fetch('http://auth.socks.invalid/authenticated', { + dispatcher, + }); + + expect(await response.text()).toBe('hello from auth.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'auth.socks.invalid', + port: 80, + username: 'mcode user', + }, + ]); + }); + + it('fails the request when SOCKS5 authentication is rejected', async () => { + const socks = await startSocksServer({ + username: 'mcode', + password: 'expected', + }); + const dispatcher = track( + createTuiNetworkDispatcher({ + ALL_PROXY: `socks5://mcode:wrong@127.0.0.1:${socks.port}`, + }), + ); + + await expect(fetch('http://denied.socks.invalid/', { dispatcher })).rejects.toThrow(); + expect(socks.connects).toEqual([]); + expect(targetRequests).toEqual([]); + }); + + it('sends NO_PROXY hosts directly even when a SOCKS5 proxy is configured', async () => { + const socks = await startSocksServer(); + const dispatcher = track( + createTuiNetworkDispatcher({ + ALL_PROXY: `socks5://127.0.0.1:${socks.port}`, + NO_PROXY: 'internal.example.invalid', + }), + ); + + const response = await fetch(`http://127.0.0.1:${targetPort}/direct`, { + dispatcher, + }); + + expect(await response.text()).toBe(`hello from 127.0.0.1:${targetPort}`); + expect(targetRequests).toEqual([`127.0.0.1:${targetPort}/direct`]); + expect(socks.connections.count).toBe(0); + }); + + it('installs a SOCKS5 global dispatcher at startup instead of failing (#393)', async () => { + const socks = await startSocksServer(); + const warnings: string[] = []; + let installed: Dispatcher | undefined; + + const configuration = configureTuiNetworkProxy({ + environment: { ALL_PROXY: `socks5://127.0.0.1:${socks.port}` }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration.mode).toBe('proxy'); + expect(warnings).toEqual([]); + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://startup.socks.invalid/', { + dispatcher: installed, + }); + expect(await response.text()).toBe('hello from startup.socks.invalid'); + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'startup.socks.invalid', port: 80 }, + ]); + }); + + it.each(['ALL_PROXY', 'HTTPS_PROXY'])( + 'accepts %s=socks5h:// and lets the proxy resolve host names', + async (name) => { + const socks = await startSocksServer({ username: 'mcode', password: 's3cr3t' }); + const warnings: string[] = []; + let installed: Dispatcher | undefined; + + const configuration = configureTuiNetworkProxy({ + environment: { + [name]: ` socks5h://mcode:s3cr3t@127.0.0.1:${socks.port} `, + HTTP_PROXY: `socks5h://mcode:s3cr3t@127.0.0.1:${socks.port}`, + }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration.mode).toBe('proxy'); + expect(warnings).toEqual([]); + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://remote-dns.socks.invalid/h', { dispatcher: installed }); + expect(await response.text()).toBe('hello from remote-dns.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'remote-dns.socks.invalid', + port: 80, + username: 'mcode', + }, + ]); + }, + ); + + it.skipIf(!HAS_IPV6_LOOPBACK)( + 'connects to a SOCKS5 proxy at an IPv6 literal address', + async () => { + const socks = await startSocksServer({ username: 'mcode', password: 'p@ss' }, '::1'); + const dispatcher = track( + createTuiNetworkDispatcher({ ALL_PROXY: `socks5://mcode:p%40ss@[::1]:${socks.port}` }), + ); + + const response = await fetch('http://ipv6-proxy.socks.invalid/', { dispatcher }); + + expect(await response.text()).toBe('hello from ipv6-proxy.socks.invalid'); + expect(socks.connects).toEqual([ + { + addressType: ATYP_DOMAIN_NAME, + host: 'ipv6-proxy.socks.invalid', + port: 80, + username: 'mcode', + }, + ]); + }, + ); + + it('treats an empty password as no SOCKS5 credentials', async () => { + const socks = await startSocksServer(); + const dispatcher = track( + createTuiNetworkDispatcher({ ALL_PROXY: `socks5://mcode:@127.0.0.1:${socks.port}` }), + ); + + const response = await fetch('http://empty-password.socks.invalid/', { dispatcher }); + + expect(await response.text()).toBe('hello from empty-password.socks.invalid'); + expect(socks.connects).toEqual([ + { addressType: ATYP_DOMAIN_NAME, host: 'empty-password.socks.invalid', port: 80 }, + ]); + }); + + it('starts without a proxy when ALL_PROXY has malformed credentials', () => { + const warnings: string[] = []; + let installed = false; + + const configuration = configureTuiNetworkProxy({ + environment: { ALL_PROXY: 'socks5://user:50%off@127.0.0.1:1080' }, + setGlobalDispatcher: () => { + installed = true; + }, + installFetch: () => undefined, + writeWarning: (message) => warnings.push(message), + }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(installed).toBe(false); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('ALL_PROXY=socks5://***:***@127.0.0.1:1080'); + expect(warnings[0]).not.toContain('50%off'); + }); + + it('keeps explicit proxies at startup when ALL_PROXY has malformed credentials', async () => { + const socks = await startSocksServer(); + let installed: Dispatcher | undefined; + + configureTuiNetworkProxy({ + environment: { + ALL_PROXY: 'socks5://user:50%off@127.0.0.1:1080', + HTTP_PROXY: `socks5://127.0.0.1:${socks.port}`, + }, + setGlobalDispatcher: (dispatcher) => { + installed = track(dispatcher); + }, + installFetch: () => undefined, + writeWarning: () => undefined, + }); + + if (!installed) throw new Error('Expected a global dispatcher'); + const response = await fetch('http://explicit.socks.invalid/', { dispatcher: installed }); + expect(await response.text()).toBe('hello from explicit.socks.invalid'); + }); +}); diff --git a/packages/tui/test/unit/network-proxy.test.ts b/packages/tui/test/unit/network-proxy.test.ts index 4f9ee7af2..cc0107cda 100644 --- a/packages/tui/test/unit/network-proxy.test.ts +++ b/packages/tui/test/unit/network-proxy.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + redactProxyUrl, resolveTuiProxyConfiguration, shouldBypassTuiProxy, + withoutSocks5ExperimentalWarning, } from '../../src/cli/network-proxy.js'; const PROXY_URL = 'http://proxy.example.invalid:8080'; @@ -68,3 +70,248 @@ describe('NO_PROXY environment precedence', () => { ); }); }); + +describe('proxy URL protocol classification', () => { + function resolveWithWarnings(environment: NodeJS.ProcessEnv) { + const warnings: string[] = []; + const configuration = resolveTuiProxyConfiguration(environment, { + writeWarning: (message) => warnings.push(message), + }); + return { configuration, warnings }; + } + + it.each([ + ['ALL_PROXY', 'socks5://127.0.0.1:7891'], + ['all_proxy', 'socks5://127.0.0.1:7891'], + ['ALL_PROXY', ' socks5://127.0.0.1:7891 \t'], + ['all_proxy', ' socks://127.0.0.1:7891\n'], + ['ALL_PROXY', 'socks5h://127.0.0.1:7891'], + ['all_proxy', ' socks5h://user:secret@127.0.0.1:7891 '], + ])('uses %s=%j as the fallback for both schemes', (name, value) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(warnings).toEqual([]); + expect(configuration).toEqual({ + mode: 'proxy', + httpProxy: value.trim(), + httpsProxy: value.trim(), + noProxy: 'localhost,127.0.0.1,::1', + }); + }); + + it.each([ + ['HTTPS_PROXY', 'socks5://127.0.0.1:7891'], + ['https_proxy', ' socks5://user:secret@127.0.0.1:7891 '], + ['HTTP_PROXY', 'socks://127.0.0.1:7891'], + ['http_proxy', 'socks5://127.0.0.1:7891'], + ['HTTPS_PROXY', 'socks5h://127.0.0.1:7891'], + ['https_proxy', ' socks5h://user:secret@127.0.0.1:7891 '], + ['HTTP_PROXY', 'socks5h://127.0.0.1:7891'], + ['http_proxy', 'socks5h://127.0.0.1:7891'], + ])('accepts an explicit %s=%j SOCKS5 proxy', (name, value) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(warnings).toEqual([]); + expect(configuration.mode).toBe('proxy'); + if (configuration.mode !== 'proxy') throw new Error('Expected proxy configuration'); + expect(configuration.httpsProxy).toBe(value.trim()); + }); + + it('prefers explicit HTTP(S)_PROXY values over ALL_PROXY', () => { + const { configuration } = resolveWithWarnings({ + http_proxy: ' http://127.0.0.1:7890 ', + HTTPS_PROXY: 'http://127.0.0.1:7890', + ALL_PROXY: 'socks5://127.0.0.1:7891', + }); + + expect(configuration).toMatchObject({ + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7890', + }); + }); + + it.each([ + [ + 'all_proxy', + ' socks4://127.0.0.1:7891 ', + 'socks4:// proxies are not supported (use http://, https://, socks5://, or socks5h://)', + ], + ['ALL_PROXY', 'socks4a://127.0.0.1:7891', 'socks4a:// proxies are not supported'], + ['ALL_PROXY', '127.0.0.1:7891', 'it is not a valid proxy URL'], + ['all_proxy', 'http://', 'it is not a valid proxy URL'], + ])( + 'ignores unsupported %s=%j with one warning instead of failing startup', + (name, value, reason) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`Warning: ignoring ${name}`); + expect(warnings[0]).toContain(reason); + expect(warnings[0]).toContain( + "Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port", + ); + }, + ); + + it('keeps explicit proxies when an unsupported ALL_PROXY is ignored', () => { + const { configuration, warnings } = resolveWithWarnings({ + HTTP_PROXY: 'http://127.0.0.1:7890', + ALL_PROXY: 'socks4://127.0.0.1:7891', + }); + + expect(warnings).toHaveLength(1); + expect(configuration).toMatchObject({ + mode: 'proxy', + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7890', + }); + }); + + it('discards warnings when no sink is provided', () => { + expect(resolveTuiProxyConfiguration({ ALL_PROXY: 'socks4://127.0.0.1:1080' })).toEqual({ + mode: 'direct', + }); + }); + + it.each([ + ['HTTPS_PROXY', 'socks4://127.0.0.1:1080'], + ['https_proxy', 'ftp://proxy.example.invalid'], + ['HTTP_PROXY', 'not a url'], + ['http_proxy', ' 127.0.0.1:7890 '], + ])('still rejects an explicit invalid %s=%j', (name, value) => { + expect(() => resolveTuiProxyConfiguration({ [name]: value })).toThrow( + `${name} must be an http://, https://, socks5://, or socks5h:// URL.`, + ); + }); + + it('redacts proxy credentials from warnings and errors', () => { + const { warnings } = resolveWithWarnings({ + ALL_PROXY: 'socks4://alice:s3cr3t@127.0.0.1:1080', + }); + expect(warnings[0]).toContain('ALL_PROXY=socks4://***:***@127.0.0.1:1080'); + + let message = ''; + try { + resolveTuiProxyConfiguration({ https_proxy: 'socks4://alice:s3cr3t@127.0.0.1:1080' }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toBe('https_proxy must be an http://, https://, socks5://, or socks5h:// URL.'); + + for (const text of [warnings[0], message]) { + expect(text).not.toContain('alice'); + expect(text).not.toContain('s3cr3t'); + } + }); + + it.each([ + ['ALL_PROXY', 'socks5://user:50%off@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['all_proxy', ' http://user:50%off@127.0.0.1:7890 ', 'http://***:***@127.0.0.1:7890'], + ['ALL_PROXY', 'socks5h://bad%zzname:pw@127.0.0.1:1080', 'socks5h://***:***@127.0.0.1:1080'], + ])('ignores %s=%j with malformed credentials and one warning', (name, value, redacted) => { + const { configuration, warnings } = resolveWithWarnings({ [name]: value }); + + expect(configuration).toEqual({ mode: 'direct' }); + expect(warnings).toEqual([ + `Warning: ignoring ${name}=${redacted} because its user name or password is not valid percent-encoding (encode % as %25); it will not be used. Set HTTPS_PROXY and HTTP_PROXY to your proxy app's http:// port, or set ALL_PROXY to a socks5:// or socks5h:// URL.`, + ]); + }); + + it.each([ + ['HTTPS_PROXY', 'socks5://user:50%off@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['https_proxy', 'http://user:50%off@127.0.0.1:7890', 'http://***:***@127.0.0.1:7890'], + ['HTTP_PROXY', 'socks5h://bad%zzname@127.0.0.1:1080', 'socks5h://***@127.0.0.1:1080'], + ])('rejects explicit %s=%j with malformed credentials', (name, value, redacted) => { + let message = ''; + try { + resolveTuiProxyConfiguration({ [name]: value }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toBe( + `${name}=${redacted} has a user name or password that is not valid percent-encoding. Encode reserved characters, for example % as %25.`, + ); + expect(message).not.toContain('50%off'); + expect(message).not.toContain('bad%zz'); + }); + + it('accepts correctly percent-encoded credentials', () => { + expect( + resolveTuiProxyConfiguration({ ALL_PROXY: 'socks5://us%40er:50%25off@127.0.0.1:1080' }), + ).toMatchObject({ mode: 'proxy' }); + }); + + it.each([ + ['socks5://alice:s3cr3t@127.0.0.1:1080', 'socks5://***:***@127.0.0.1:1080'], + ['socks5://alice@127.0.0.1:1080', 'socks5://***@127.0.0.1:1080'], + ['socks5h://alice:s3cr3t@127.0.0.1:1080', 'socks5h://***:***@127.0.0.1:1080'], + ['http://127.0.0.1:7890/', 'http://127.0.0.1:7890'], + ['http://user%40corp:p%3Ass@proxy.example.invalid', 'http://***:***@proxy.example.invalid'], + ])('redactProxyUrl(%j) masks credentials', (value, expected) => { + expect(redactProxyUrl(value)).toBe(expected); + }); + + it('does not echo an unparseable proxy value', () => { + expect(redactProxyUrl('user:secret@@ bad')).toBeUndefined(); + const { warnings } = resolveWithWarnings({ ALL_PROXY: 'user:secret@@ bad' }); + expect(warnings[0]).not.toContain('secret'); + }); +}); + +describe('SOCKS5 experimental warning filter', () => { + const SOCKS5_WARNING = 'SOCKS5 proxy support is experimental and subject to change'; + + function createTarget() { + const emitted: unknown[][] = []; + const original = ((...args: unknown[]) => { + emitted.push(args); + }) as typeof process.emitWarning; + return { target: { emitWarning: original }, original, emitted }; + } + + it('drops only the SOCKS5 ExperimentalWarning and restores the emitter', () => { + const { target, original, emitted } = createTarget(); + + const result = withoutSocks5ExperimentalWarning(() => { + target.emitWarning(SOCKS5_WARNING, 'ExperimentalWarning'); + target.emitWarning(SOCKS5_WARNING, { type: 'ExperimentalWarning' }); + target.emitWarning('Another feature is experimental', 'ExperimentalWarning'); + target.emitWarning(SOCKS5_WARNING, 'DeprecationWarning'); + target.emitWarning(SOCKS5_WARNING); + target.emitWarning('Unrelated warning'); + return 'created'; + }, target); + + expect(result).toBe('created'); + expect(emitted).toEqual([ + ['Another feature is experimental', 'ExperimentalWarning'], + [SOCKS5_WARNING, 'DeprecationWarning'], + [SOCKS5_WARNING], + ['Unrelated warning'], + ]); + expect(target.emitWarning).toBe(original); + }); + + it('drops an Error-shaped SOCKS5 ExperimentalWarning', () => { + const { target, emitted } = createTarget(); + const warning = new Error(SOCKS5_WARNING); + warning.name = 'ExperimentalWarning'; + + withoutSocks5ExperimentalWarning(() => target.emitWarning(warning), target); + + expect(emitted).toEqual([]); + }); + + it('restores the emitter when creation throws', () => { + const { target, original } = createTarget(); + + expect(() => + withoutSocks5ExperimentalWarning(() => { + throw new Error('construction failed'); + }, target), + ).toThrow('construction failed'); + expect(target.emitWarning).toBe(original); + target.emitWarning('after'); + }); +}); diff --git a/packages/tui/test/unit/run-coordinator.test.ts b/packages/tui/test/unit/run-coordinator.test.ts index 91eafa1e8..86931251c 100644 --- a/packages/tui/test/unit/run-coordinator.test.ts +++ b/packages/tui/test/unit/run-coordinator.test.ts @@ -237,6 +237,34 @@ describe('TuiRunCoordinator', () => { ); }); + it('fires timeouts above the Node timer limit only at the requested deadline', async () => { + vi.useFakeTimers(); + try { + const timeoutRuntime = createRuntime(async function* pendingTimeout(_req, signal) { + yield* []; + await new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }), + ); + }); + // 200 ms beyond Node's 32-bit timer limit; a plain setTimeout would + // clamp this to 1 ms and cancel the run almost immediately. + const timed = new TuiRunCoordinator(timeoutRuntime, { nowMs: () => 1_000 }).execute({ + ...request('turn-timeout-overflow'), + policy: { timeoutMs: 2_147_483_847 }, + }); + expect(timeoutRuntime.abortSession).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2_147_483_647); + expect(timeoutRuntime.abortSession).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + await expect(timed).resolves.toMatchObject({ status: 'timeout' }); + expect(timeoutRuntime.abortSession).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'timeout' }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('reports a Runtime questionnaire as awaiting continuation without aborting it', async () => { const runtime = createRuntime(async function* () { yield { diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 31b76c817..b8db8f42d 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -132,6 +132,16 @@ function createObservability(): TuiObservability { }; } +const INTERACTIVE_CAPABILITIES: TerminalCapabilities = { + terminalId: "unknown", + platform: "linux", + isTTY: true, + transport: "local", + multiplexer: "none", + color: true, + colorLevel: 3, +}; + class FakeTerminal implements Terminal { columns = 80; rows = 18; @@ -2539,6 +2549,7 @@ describe("createTuiApp", () => { const app = createTuiApp({ runtime, terminal, + terminalCapabilities: INTERACTIVE_CAPABILITIES, version: "0.1.0", workspaceDir: "/workspace", homeDir: "/home/dev", @@ -2556,6 +2567,10 @@ describe("createTuiApp", () => { expect(conversation).toContain("Hello from the Agent"); expect(terminal.started).toBe(true); expect(terminal.title).toBe("Kinetick Code"); + const programReports = () => + terminal.writes.filter((value) => value.startsWith("\u001b]7501;")); + expect(programReports()).toContain("\u001b]7501;state=working:app=kcode\u001b\\"); + expect(programReports().at(-1)).toBe("\u001b]7501;state=done:app=kcode\u001b\\"); expect(runtime.createSession).toHaveBeenCalledWith({ workspaceDir: "/workspace", }); @@ -2571,6 +2586,7 @@ describe("createTuiApp", () => { await app.stop(); expect(terminal.stopped).toBe(true); + expect(programReports().at(-1)).toBe("\u001b]7501;state=done:app=kcode\u001b\\"); }); it("syncs the generated Session title to the terminal without duplicate writes", async () => { @@ -9550,6 +9566,96 @@ describe("createTuiApp", () => { await app.stop(); }); + it("reports permission and question waits to the title and OSC 7501 without build-mode", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessions).mockResolvedValue([ + { + sessionId: "session-existing", + title: "Existing session", + workspaceDir: "/workspace", + }, + ]); + vi.mocked(runtime.listPendingPermissions).mockResolvedValue([ + { + requestId: "permission-1", + sessionId: "session-existing", + agentName: "mavis", + toolName: "bash", + reason: "Run tests", + }, + ]); + // The permission arrives while its Turn is active, as in a real approval prompt. + vi.mocked(runtime.getActiveRun).mockImplementation(async (sessionId: string) => ({ + schemaVersion: 1, + sessionId, + state: "decision-blocked", + turnId: "turn-permission", + actions: { steer: false }, + })); + vi.mocked(runtime.getPendingQuestionnaire).mockResolvedValue({ + id: "question-1", + title: "Choose", + requester: { sessionId: "session-existing", runId: "turn-question" }, + steps: [ + { + id: "step-1", + question: "Proceed?", + selectionMode: "single", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + ], + }); + // Default configuration: no status line items, so no build-mode line. + const app = createTuiApp({ + runtime, + terminal, + terminalCapabilities: INTERACTIVE_CAPABILITIES, + version: "0.1.0", + workspaceDir: "/workspace", + }); + const programReports = () => + terminal.writes.filter((value) => value.startsWith("\u001b]7501;")); + + app.start(); + await app.ready; + await app.submit("/resume session-existing"); + await vi.waitFor(() => expect(terminal.title).toBe("Existing session")); + expect(programReports().at(-1)).toBe( + "\u001b]7501;state=blocked:kind=permission:app=kcode\u001b\\", + ); + + await app.submit("/allow"); + await vi.waitFor(() => expect(terminal.title).toBe("Existing session")); + expect(programReports().at(-1)).toBe( + "\u001b]7501;state=blocked:kind=question:app=kcode\u001b\\", + ); + expect(app.tui.render(120).join("\n")).not.toContain("[V]"); + await app.stop(); + }); + + it("does not report OSC 7501 when tui.programStatus is false", async () => { + const terminal = new FakeTerminal(); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + terminalCapabilities: INTERACTIVE_CAPABILITIES, + version: "0.1.0", + workspaceDir: "/workspace", + programStatus: false, + }); + + app.start(); + await app.ready; + await app.submit("Say hello"); + expect(terminal.title).toBe("Kinetick Code"); + await app.stop(); + expect(terminal.writes.some((value) => value.includes("\u001b]7501;"))).toBe(false); + }); + it("shows account status and resolves pending permission and questionnaire decisions", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-terminal-presentation.test.ts b/packages/tui/test/unit/tui-terminal-presentation.test.ts index ee09ab847..2ffaebc99 100644 --- a/packages/tui/test/unit/tui-terminal-presentation.test.ts +++ b/packages/tui/test/unit/tui-terminal-presentation.test.ts @@ -7,6 +7,10 @@ import { TuiTerminalNotifications, } from '../../src/tui/platform/terminal-notifications.js'; import { formatTuiTerminalTitle, TuiTerminalTitle } from '../../src/tui/platform/terminal-title.js'; +import { + TuiTerminalProgramStatus, + type TuiProgramStatus, +} from '../../src/tui/platform/terminal-program-status.js'; const capabilities = ( env: NodeJS.ProcessEnv = {}, @@ -31,6 +35,9 @@ describe('terminal title ownership', () => { expect(formatTuiTerminalTitle({ ...input, title: 'New session' })).toBe( 'Needs approval | workspace (session-) | KCode', ); + expect(formatTuiTerminalTitle({ ...input, status: 'auth' })).toBe( + 'Needs login | 修复 登录 | KCode', + ); const long = formatTuiTerminalTitle({ ...input, title: '😀'.repeat(300) })!; expect(Array.from(long)).toHaveLength(240); expect(long).not.toContain('\ufffd'); @@ -89,6 +96,96 @@ describe('terminal title ownership', () => { }); }); +describe('OSC 7501 program status', () => { + it.each([ + ['ready', 'state=idle'], + ['run', 'state=working'], + ['perm', 'state=blocked:kind=permission'], + ['plan', 'state=blocked:kind=permission'], + ['ask', 'state=blocked:kind=question'], + ['auth', 'state=blocked:kind=auth'], + ['done', 'state=done'], + ['fail', 'state=error'], + ['error', 'state=error'], + ['cancel', 'state=idle'], + ] as const)('reports %s using fixed protocol metadata', (state, body) => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update(state); + status.update(state); + expect(terminal.write.mock.calls).toEqual([[`\u001b]7501;${body}:app=kcode\u001b\\`]]); + // Only fixed metadata leaves the TUI: no id, msg, title or progress pairs. + const sequence = terminal.write.mock.calls[0]![0] as string; + const keys = sequence + .slice('\u001b]7501;'.length, -'\u001b\\'.length) + .split(':') + .map((pair) => pair.split('=')[0]); + expect(keys.every((key) => ['state', 'kind', 'app'].includes(key!))).toBe(true); + }); + + it('releases a suspended TUI, ignores late updates and reapplies status on resume', () => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.update('run'); + expect(terminal.write).not.toHaveBeenCalled(); + status.setActive(true); + status.update('perm'); + status.update('plan'); + status.setActive(false); + status.update('ask'); + status.setActive(true); + status.update('ask'); + status.dispose(); + status.setActive(true); + status.update('run'); + expect(terminal.write.mock.calls.flat()).toEqual([ + '\u001b]7501;state=blocked:kind=permission:app=kcode\u001b\\', + '\u001b]7501;state=idle:app=kcode\u001b\\', + '\u001b]7501;state=blocked:kind=question:app=kcode\u001b\\', + '\u001b]7501;state=idle:app=kcode\u001b\\', + ]); + }); + + it.each(['done', 'fail', 'error'] as const)('preserves %s on exit', (state) => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update(state); + status.dispose(); + status.dispose(); + status.update('ready'); + expect(terminal.write).toHaveBeenCalledTimes(1); + }); + + it('never writes to a non-TTY or clears a record it did not write', () => { + const terminal = { write: vi.fn() }; + for (const isTTY of [false, true]) { + const status = new TuiTerminalProgramStatus(terminal, isTTY); + status.setActive(true); + if (!isTTY) status.update('run'); + status.setActive(false); + status.dispose(); + } + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it('ignores unknown states and retries failed writes without interrupting the caller', () => { + const terminal = { + write: vi.fn().mockImplementationOnce(() => { + throw new Error('closed'); + }), + }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update('unknown' as TuiProgramStatus); + expect(() => status.update('run')).not.toThrow(); + status.update('run'); + status.update('run'); + expect(terminal.write).toHaveBeenCalledTimes(2); + }); +}); + describe('terminal notification policy and transport', () => { it('preserves live focus through the production wrapper and suppresses each foreground event once', () => { const base = { focused: true, write: vi.fn() }; diff --git a/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts b/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts index 3af7344b7..cbfa01ead 100644 --- a/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts @@ -38,6 +38,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -101,6 +102,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -158,6 +160,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -215,6 +218,7 @@ describe("TuiChromeFlow agent status sequence", () => { let backgroundTasks = 0; const shells: TuiShellState[] = []; const setTerminalTitle = vi.fn(); + const setProgramStatus = vi.fn(); const shellSink = { setState: (state: TuiShellState) => shells.push(state), }; @@ -225,6 +229,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle, + setProgramStatus, connection: () => ({ phase: "live", generation: 1 }), liveRunId: (snapshot) => snapshot.activeTurnId, runProjection: () => ({ @@ -296,6 +301,8 @@ describe("TuiChromeFlow agent status sequence", () => { }; flow.update(interactingSnapshot); expect(setTerminalTitle).toHaveBeenCalledTimes(1); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Kinetick Code"); + expect(setProgramStatus).toHaveBeenLastCalledWith("perm"); expect(shells.at(-1)).toMatchObject({ agentSeq: "4", agentStatus: "perm", @@ -306,6 +313,8 @@ describe("TuiChromeFlow agent status sequence", () => { flow.update(interactingSnapshot); flow.update(interactingSnapshot); expect(setTerminalTitle).toHaveBeenCalledTimes(1); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Kinetick Code"); + expect(setProgramStatus).toHaveBeenLastCalledWith("run"); expect(shells.at(-1)).toMatchObject({ agentSeq: "5", agentStatus: "run", @@ -375,5 +384,49 @@ describe("TuiChromeFlow agent status sequence", () => { }); expect(shells.at(-1)).toMatchObject({ agentStatus: "ready" }); expect(shells.at(-1)?.agentRunId).toBeUndefined(); + expect(setProgramStatus).toHaveBeenLastCalledWith("ready"); + + flow.update({ + ...snapshot("session-3"), + account: { status: "needs-login", authMode: "managed-login" } as TuiChatSnapshot["account"], + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("auth"); + // The machine line keeps its fatal-error contract; human-facing outputs say why. + expect(shells.at(-1)).toMatchObject({ agentStatus: "error" }); + expect(shells.at(-1)?.agentRunId).toBeUndefined(); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Kinetick Code"); + const authenticated = { + ...snapshot("session-3"), + account: { + status: "ready", + authMode: "managed-login", + managedTokenPresent: true, + } as TuiChatSnapshot["account"], + }; + flow.update(authenticated); + expect(setProgramStatus).toHaveBeenLastCalledWith("ready"); + expect(shells.at(-1)).toMatchObject({ agentStatus: "ready" }); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Kinetick Code"); + flow.update({ ...authenticated, status: "running", activeTurnId: "after-login" }); + expect(setProgramStatus).toHaveBeenLastCalledWith("run"); + flow.update({ + ...authenticated, + lastSettledTurn: { sessionId: "session-3", turnId: "after-login", status: "succeeded" }, + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("done"); + flow.update(authenticated); + expect(setProgramStatus).toHaveBeenLastCalledWith("done"); + + flow.update({ ...snapshot("fatal-session"), status: "error", errorRetryable: false }); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + flow.update(snapshot("fatal-session")); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + // A fatal error is not downgraded to a recoverable login wait. + flow.update({ + ...snapshot("fatal-session"), + account: { status: "needs-login", authMode: "managed-login" } as TuiChatSnapshot["account"], + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Kinetick Code"); }); }); diff --git a/packages/tui/test/unit/tui/controller/projection/turn-output-rate.test.ts b/packages/tui/test/unit/tui/controller/projection/turn-output-rate.test.ts index 356de24bc..6a347d8f1 100644 --- a/packages/tui/test/unit/tui/controller/projection/turn-output-rate.test.ts +++ b/packages/tui/test/unit/tui/controller/projection/turn-output-rate.test.ts @@ -209,6 +209,29 @@ describe("TuiTurnOutputRate", () => { expect(rate.finalize()).toBeUndefined(); }); + it("drops a buffered burst at turn start instead of reporting an inflated rate", () => { + const rate = new TuiTurnOutputRate(); + rate.beginTurn("turn-1"); + const apply = ( + id: string, + outputTokens: number, + decodeDurationMs: number, + requestDurationMs: number, + ) => + rate.apply("turn-1", { + type: "message", + message: { + id, + role: "assistant", + usage: { outputTokens, decodeDurationMs, requestDurationMs }, + }, + }); + expect(apply("burst", 107, 2, 2_221)).toBeUndefined(); + expect(apply("instant-burst", 100, 0, 3_000)).toBeUndefined(); + expect(apply("normal", 400, 2_000, 4_000)).toBe(200); + expect(apply("fast-provider", 1_500, 1_000, 1_200)).toBe(1_900 / 3); + }); + it("ignores late events from an older turn after reset", () => { const rate = new TuiTurnOutputRate(); rate.beginTurn("turn-old"); diff --git a/release/public-source.json b/release/public-source.json index 1eaed4357..9d32900bf 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1339,6 +1339,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/host-capability/skill-disclosure.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", @@ -1354,6 +1355,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/contracts.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/session-identity.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/token-usage.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/contracts.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/empty-dependencies.ts", @@ -1461,6 +1463,8 @@ "packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/background-cadence-reminder.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/reminder-admission.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/todo-cadence-reminder.ts", "packages/local-runtime-v2/src/service/turn-system/execution/steering/steer-session.service.ts", "packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts", @@ -2753,6 +2757,7 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-testkit.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", + "packages/local-runtime/test/unit/turn-reminder-context.test.ts", "packages/mcode-tools-host/README.md", "packages/mcode-tools-host/package.json", "packages/mcode-tools-host/src/contracts.ts", @@ -3220,6 +3225,7 @@ "packages/tui/src/tui/platform/terminal-capabilities.ts", "packages/tui/src/tui/platform/terminal-clipboard.ts", "packages/tui/src/tui/platform/terminal-notifications.ts", + "packages/tui/src/tui/platform/terminal-program-status.ts", "packages/tui/src/tui/platform/terminal-title.ts", "packages/tui/src/tui/product-features.ts", "packages/tui/src/tui/renderer/index.ts", @@ -3342,6 +3348,7 @@ "packages/tui/test/unit/host-tui-settings.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", + "packages/tui/test/unit/network-proxy-socks5.test.ts", "packages/tui/test/unit/network-proxy.test.ts", "packages/tui/test/unit/observability.test.ts", "packages/tui/test/unit/package-identity.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 1f4b454f7..0d52f34d7 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -24,6 +24,7 @@ "packages/tui/test/unit/auth-session.test.ts", "packages/tui/test/unit/cli-auth-command.test.ts", "packages/tui/test/unit/network-proxy.test.ts", + "packages/tui/test/unit/network-proxy-socks5.test.ts", "packages/tui/test/unit/application-login-gate.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/provider-application.test.ts", @@ -74,6 +75,7 @@ "packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts", "packages/local-runtime/test/unit/child-bash-lifecycle.test.ts", "packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts", + "packages/local-runtime/test/unit/turn-reminder-context.test.ts", "packages/mcode-tools-host/test/unit/resource-manifest.test.ts", "packages/tui/test/unit/headless-config.test.ts", "packages/tui/test/unit/runtime-data-dir.test.ts", @@ -118,7 +120,9 @@ "packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts", "packages/agent-core/test/unit/pi-turn-runner/request-image-limit.test.ts", "packages/tui/test/unit/runtime-error-presentation-rejection.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/reminder/session-identity-reminder.test.ts", "packages/local-runtime-v2/src/service/sandbox/effective-policy.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/preparation/prompt-blocks.test.ts",