From 5c4ed7bd06c5ea86d6c3c9d02eaaf0f646c6aa7e Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 15 Jul 2026 00:34:26 +0800 Subject: [PATCH 1/3] refactor(agent): simplify core boundaries --- docs/architecture/agent-system.md | 9 ++ .../permission-mode-policy-owner/spec.md | 83 ++++++++++++++ .../provider-round-stop-contract/spec.md | 98 +++++++++++++++++ .../send-message-canonical-boundary/spec.md | 103 ++++++++++++++++++ .../spec.md | 65 ----------- .../agent/acp/instance/acpAgentInstance.ts | 2 +- .../agent/acp/instance/acpAgentRuntime.ts | 8 +- src/main/agent/acp/instance/ports.ts | 15 ++- .../agent/acp/runtime/acpContentMapper.ts | 20 ++-- src/main/agent/acp/runtime/index.ts | 2 +- .../agent/deepchat/deepChatAgentRepository.ts | 2 +- .../pending/pendingInputCoordinator.ts | 38 ++----- .../deepchat/pending/pendingInputStore.ts | 64 ++++------- .../agent/manager/deepChatAgentBackend.ts | 8 +- src/main/agent/manager/sessionHandles.ts | 6 +- src/main/agent/shared/agentSessionHandle.ts | 2 +- .../agentRuntimePresenter/accumulator.ts | 15 +-- .../acpCompatibilityAdapters.ts | 6 +- .../acpCompatibilityDependencies.ts | 18 +-- .../compactionService.ts | 2 +- .../agentRuntimePresenter/contextBuilder.ts | 11 +- .../deferredToolExecutor.ts | 53 ++++----- .../generationSettings.ts | 2 +- .../presenter/agentRuntimePresenter/index.ts | 32 +++--- .../agentRuntimePresenter/process.ts | 54 ++++++++- .../sessionSettingsCoordinator.ts | 19 ++-- .../agentRuntimePresenter/sessionStore.ts | 2 +- .../agentRuntimePresenter/tapeViewPolicy.ts | 2 +- .../agentRuntimePresenter/turnCoordinator.ts | 37 +++---- .../presenter/agentRuntimePresenter/types.ts | 6 +- .../aiSdk/streamAdapter.ts | 64 ++++++----- .../providers/acpProvider.ts | 4 +- .../agentAssignmentPolicy.ts | 21 ++-- .../sessionApplication/turnCoordinator.ts | 2 +- .../tables/deepchatSessions.ts | 8 +- .../contracts/routes/sessions.routes.ts | 6 +- src/shared/types/agent-interface.d.ts | 2 +- src/shared/types/core/llm-events.ts | 13 ++- .../acp/instance/acpAgentRuntime.test.ts | 11 +- .../acp/runtime/acpContentMapper.test.ts | 28 +++-- .../pending/pendingInputStore.test.ts | 46 +++++++- .../manager/deepChatAgentBackend.test.ts | 27 +++-- .../manager/directAcpAgentBackend.test.ts | 4 +- test/main/presenter/agentRepository.test.ts | 7 +- .../agentRuntimePresenter/accumulator.test.ts | 3 + .../acpCompatibilityAdapters.test.ts | 26 +++++ .../agentRuntimePresenter.test.ts | 84 +++++++++++--- .../compactionService.test.ts | 20 ++-- .../contextBuilder.test.ts | 24 +++- .../agentRuntimePresenter/process.test.ts | 53 ++++++++- .../tapeViewAssembler.test.ts | 25 +++-- .../tapeViewPolicy.test.ts | 2 +- .../aiSdkStreamAdapter.test.ts | 75 ++++++++++++- .../agentAssignmentPolicy.test.ts | 21 +++- .../runtimeIntegration.test.ts | 26 ++--- .../turnCoordinator.test.ts | 42 ++++++- test/main/presenter/sqlitePresenter.test.ts | 4 +- .../deepchatSessionsTable.test.ts | 16 ++- test/main/presenter/usageStatsService.test.ts | 8 +- test/main/routes/contracts.test.ts | 56 ++++++++++ 60 files changed, 1074 insertions(+), 438 deletions(-) create mode 100644 docs/architecture/permission-mode-policy-owner/spec.md create mode 100644 docs/architecture/provider-round-stop-contract/spec.md create mode 100644 docs/architecture/send-message-canonical-boundary/spec.md delete mode 100644 docs/issues/agent-terminal-outcome-consistency/spec.md diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md index 66892ecea8..b6c9f3840a 100644 --- a/docs/architecture/agent-system.md +++ b/docs/architecture/agent-system.md @@ -7,6 +7,15 @@ DeepChat 与 ACP 的执行路径对比见 [deepchat-vs-acp-agents/](./deepchat-vs-acp-agents/)。 +当前消息与权限边界由以下维护合同定义: + +- [Send Message Canonical Boundary](./send-message-canonical-boundary/spec.md):route/application + compatibility 与 runtime canonical input 的分界; +- [Permission Mode Policy Ownership](./permission-mode-policy-owner/spec.md):assignment、storage 与 + deferred execution 的权限策略 owner; +- [Provider Round Stop Contract](./provider-round-stop-contract/spec.md):AI SDK、ACP 与内部 provider + round terminal reason 的无损映射。 + ## Agent 类型与路由 DeepChat 支持两个 executable descriptor kind: diff --git a/docs/architecture/permission-mode-policy-owner/spec.md b/docs/architecture/permission-mode-policy-owner/spec.md new file mode 100644 index 0000000000..75b166544d --- /dev/null +++ b/docs/architecture/permission-mode-policy-owner/spec.md @@ -0,0 +1,83 @@ +# Permission Mode Policy Ownership + +## Status + +Implemented and validated. + +## Problem + +Before this contract was introduced, permission mode passed through several unrelated functions +named `normalizePermissionMode`. Those functions hid two different policies: + +- session assignment treats a missing value as `full_access`; +- runtime settings and deferred tool execution treat a missing or unknown value as `default`. + +The same value was defaulted again after assignment, persistence, hydration, and tool execution. +That weakened the internal contract and allowed deferred execution to use a stale or invented +permission value. + +## Goal + +Give each policy one explicit owner: + +- assignment is the only owner of the `full_access` default for omitted create, ACP draft, subagent, + and transfer configuration; +- persistence decoding owns validation of stored permission values and uses `default` for missing + or invalid stored data; +- the runtime initialization boundary owns its historical `default` fallback for direct callers; +- agent-context updates, settings updates, and deferred execution accept and propagate an exact + `PermissionMode` without normalization. + +## Acceptance Criteria + +- No function named `normalizePermissionMode` remains in main-process code. +- Assignment resolves an omitted permission mode to `full_access` and preserves all three explicit + modes. +- Persistence decoding preserves all three valid modes and resolves missing or invalid stored data + to `default`. +- Application lifecycle initialization always supplies the assignment policy's exact + `PermissionMode`; the lower-level runtime boundary keeps the existing `default` behavior for + direct callers that omit it. +- Agent-context replacement requires `PermissionMode` in its internal TypeScript contract. +- `setPermissionMode` writes the exact validated mode it receives. +- Deferred tool execution loads the session state after asynchronous tool preparation and uses that + latest permission mode without inventing a fallback. +- Persistence create APIs require an exact `PermissionMode`; they cannot default omission to + `full_access`. +- Existing renderer routes and user-visible behavior remain unchanged. +- Security-focused tests cover assignment defaulting, persistence decoding, exact runtime updates, + and deferred execution from hydrated state. + +## Constraints + +- Keep `PermissionMode` as the existing union: `default | auto_approve | full_access`. +- Do not add a policy class, registry, dependency, or shared utility module. +- Do not modify renderer code. +- Keep create and assignment behavior at `full_access` when no mode is configured. +- Keep the conservative persisted-data fallback at `default`. + +## Non-Goals + +- Changing the meaning of any permission mode. +- Changing the renderer permission selector or route payloads. +- Redesigning per-tool permission grants or auto-approval review. +- Migrating existing valid database rows. +- General cleanup of other normalization helpers. + +## Decisions + +1. Assignment defaulting remains a small named function inside `agentAssignmentPolicy.ts`; it is a + policy, not a reusable normalizer. +2. Stored data is decoded at `DeepChatSessionsTable.get`, the first point where an untrusted + database string enters the typed domain. +3. Runtime state never represents a missing permission mode. The low-level initialization boundary + resolves an omitted direct input to `default`; application lifecycle callers already supply the + assignment result. Context update contracts make the field required. +4. Deferred execution loads session state immediately before tool invocation so a permission change + made during asynchronous preparation takes effect. +5. Agent configuration merging preserves an omitted mode; only assignment resolves it to + `full_access`. Persistence create APIs require the resolved value. + +## Open Questions + +None. diff --git a/docs/architecture/provider-round-stop-contract/spec.md b/docs/architecture/provider-round-stop-contract/spec.md new file mode 100644 index 0000000000..2d8ecd86ba --- /dev/null +++ b/docs/architecture/provider-round-stop-contract/spec.md @@ -0,0 +1,98 @@ +# Provider Round Stop Contract — Spec + +## Status + +Implemented and validated. + +## Problem + +Before this contract was introduced, provider terminal reasons crossed multiple lossy translation +layers before the runtime settled a turn: + +1. AI SDK or ACP emits its protocol-specific reason. +2. A provider adapter maps it to `StopStreamEvent.stop_reason`. +3. `accumulator.ts` maps the already-typed reason again. +4. terminal settlement maps or overwrites the accumulated reason. + +Those layers hid invalid or bounded outcomes: + +- AI SDK `content-filter`, `other`, and unknown values become `complete`; +- AI SDK `abort` stream parts are ignored; +- `stop_sequence` exists only as an intermediate value and becomes `complete` later; +- ACP `max_turn_requests` becomes `stop_sequence`, then `complete`; +- ACP compatibility settlement overwrites `max_tokens` with `complete`; +- `StreamState` starts at `complete`, so a stream without a terminal event can succeed; +- `StreamState.stopReason` contains an `abort` value that is never assigned. + +## Goal + +Translate each external protocol exactly once at its provider boundary, carry one closed provider +round reason through the accumulator, and preserve the resulting terminal reason through runtime and +ACP compatibility settlement. + +## Protocol Mappings + +AI SDK reasons are converted once at the stream adapter boundary: + +| AI SDK input | Internal result | +| --- | --- | +| `stop` | `complete` | +| `length` | `max_tokens` | +| `tool-calls` | `tool_use` | +| `content-filter` | error event + `error` stop | +| `error` | error event + `error` stop | +| `other` | error event + `error` stop | +| abort stream part | error event + `error` stop | + +A parsed legacy tool call may replace only a normal `stop` with `tool_use`; it cannot override a +bounded or failed finish. A matching local abort signal remains the sole owner of `user_stop`. + +ACP reasons are converted once at the ACP content boundary: + +| ACP input | Internal result | +| --- | --- | +| `end_turn` | `complete` | +| `max_tokens` | `max_tokens` | +| `max_turn_requests` | `max_turn_requests` | +| `refusal` | error event + `error` stop | +| `cancelled` | error event + `error` stop | + +## Acceptance Criteria + +- `StopStreamEvent.stop_reason` uses one exported closed type. +- The internal provider round reasons are `complete`, `tool_use`, `max_tokens`, + `max_turn_requests`, and `error`. +- `stop_sequence` is removed from the internal event contract. +- `mapStopReason` is deleted and the accumulator assigns the typed reason directly. +- `StreamState.stopReason` starts as `null` and is reset before every provider round. +- A provider stream that ends without a terminal event settles as `provider_error`. +- AI SDK finish and abort parts are handled explicitly without a default-success branch. +- AI SDK `content-filter`, `error`, `other`, and an abort without a matching local abort signal + settle as provider errors with a useful message. +- A local abort signal remains the sole source of the `user_stop` run outcome. +- ACP `max_turn_requests` and `max_tokens` survive compatibility projection settlement. +- ACP `refusal` and protocol-level `cancelled` settle as provider errors. They do not claim a local + user stop without a matching local abort signal. +- Closed protocol enums use exhaustive switches without `default` branches. +- Focused tests cover every AI SDK finish reason, AI SDK abort, every ACP stop reason, accumulator + assignment, missing terminal events, and ACP settlement preservation. + +## Constraints + +- Do not change renderer code or route payloads. +- Do not introduce a terminal-state framework, class hierarchy, adapter registry, or dependency. +- Keep provider-round reasons separate from run-level reasons such as `user_stop`, + `max_tool_calls`, `context_window`, and `provider_error`. +- Preserve existing tool-loop behavior for `tool_use` and token-limit behavior for `max_tokens`. +- Keep raw ACP turn persistence unchanged; it already stores the protocol reason. + +## Non-goals + +- Unifying all `ProcessResult.stopReason` strings in this iteration. +- Removing the ACP provider compatibility path. +- Refactoring provider request tracing, usage accounting, or tool execution. +- Changing user-visible error presentation beyond exposing previously swallowed provider failures. + +## Open Questions + +None. diff --git a/docs/architecture/send-message-canonical-boundary/spec.md b/docs/architecture/send-message-canonical-boundary/spec.md new file mode 100644 index 0000000000..365f5126f6 --- /dev/null +++ b/docs/architecture/send-message-canonical-boundary/spec.md @@ -0,0 +1,103 @@ +# Send Message Canonical Boundary + +## Status + +Implemented and validated. + +## Problem + +The typed chat routes accept the compatibility input `string | SendMessageInput`, and session +creation has a separate legacy shape. Before this contract was introduced, the same live turn was +normalized again inside the runtime presenter, turn coordinator, context construction, and ACP +compatibility projection after the session application layer had already converted it. + +Those repeated conversions make each downstream module distrust its caller. They also hide the +actual ownership boundary: route and session-creation compatibility belongs to the session +application layer, while runtime ports should operate on one canonical object shape. + +Pending queue and update ports now use the same canonical object below the application boundary. +The store serializes that object directly; persisted payloads are decoded with +`SendMessageInputSchema`, and invalid JSON or shape is reported as corrupt storage instead of being +reinterpreted as user text. + +## Goal + +Make the session application turn coordinator the canonical boundary for live send, live steer, +and the initial session turn. After that boundary, runtime handles, backend ports, turn execution, +context construction, and ACP prompt resources consume `SendMessageInput` directly without +normalizing it again. + +## Canonical Data Flow + +```text +chat route string | SendMessageInput + -> SessionTurnCoordinator.normalizeSendMessageInput() + -> AgentSessionSendInput.content: SendMessageInput + -> backend/runtime/turn/context: SendMessageInput + +session create input + -> SessionLifecycleCoordinator.normalizeCreateSessionInput() + -> SessionInitialTurnInput.content: SendMessageInput + -> AgentSessionSendInput.content: SendMessageInput + -> backend/runtime/turn/context: SendMessageInput +``` + +## Acceptance Criteria + +1. Chat route names, input schemas, output schemas, renderer behavior, and string compatibility are + unchanged. +2. `SessionTurnCoordinator` converts live send and live steer input exactly once before resolving a + runtime session. +3. `SessionLifecycleCoordinator` remains the sole converter for the initial create-session input; + `SessionTurnCoordinator.startInitialTurn` does not normalize the result again. +4. `AgentSessionSendInput.content` and the live steer facets use `SendMessageInput`, not + `string | SendMessageInput`. +5. Pending queue and update facets below the application boundary also use `SendMessageInput`. +6. Pending storage accepts only canonical objects and rejects invalid persisted JSON or shape. +7. DeepChat turn execution and ACP prompt resource resolution receive `SendMessageInput` directly. +8. Context construction does not normalize the current live user input. Persisted history decoding + remains separate and unchanged. +9. The main-process live send, live steer, and initial-turn tests prove that text, files, active + skills, and inline items arrive at the runtime in canonical form. +10. No new manager, service, framework, dependency, renderer code, database schema, or migration is + introduced. + +## Constraints + +- Preserve the current normalization semantics at the application boundary: string input becomes + `{ text, files: [] }`; active skills are trimmed and deduplicated; falsey file entries are removed; + empty optional arrays are omitted. +- Keep persisted pending payload decoding at the storage boundary. +- Keep persisted user-message decoding tolerant in this iteration. It is a storage boundary, not a + live-turn contract. +- Keep direct calls to the legacy `AgentRuntimePresenter.processMessage` test and compatibility + facade working while ensuring canonical production calls are not re-normalized. + +## Non-Goals + +- Changing queue ordering, recovery, limits, or scheduling behavior. +- Changing permission mode, provider stop reason, cancellation, generation settings, or error + behavior. +- Changing the public route compatibility union. +- Introducing a branded or second canonical message-input type. +- Cleaning unrelated normalizers in message history, compaction records, migrations, or renderer + code. + +## Decisions + +- Reuse the existing `SendMessageInput` type. A second type would add conversion code without adding + a trust boundary. +- Reuse `normalizeSendMessageInput` only at the session application boundary. Downstream code may + validate business rules such as non-empty content, but it must not reshape the object. +- Retain a string-only compatibility conversion at the public `AgentRuntimePresenter` facade because + it is called directly by existing main-process integrations. Object inputs pass through unchanged; + internal runtime ports remain canonical. +- Narrow pending queue and update operations after the companion persistence-decoder work proved the + supported stored format and removed write-side coercion. +- Decode persisted pending payloads with `SendMessageInputSchema`; do not provide a raw-text fallback + because the table has only ever stored JSON objects. +- GitHub issue synchronization is not part of this work. + +## Open Questions + +None. diff --git a/docs/issues/agent-terminal-outcome-consistency/spec.md b/docs/issues/agent-terminal-outcome-consistency/spec.md deleted file mode 100644 index 0782b62c50..0000000000 --- a/docs/issues/agent-terminal-outcome-consistency/spec.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Terminal Outcome Consistency - -## Issue - -Several native Agent termination paths persist or return a terminal outcome that does not match the -provider/runtime condition that ended the turn. A generic provider error event can be finalized as a -successful message, while `max_tokens`, the tool-call hard cap, and context-window failures lose -their specific stop reason or usage in `ProcessResult`. - -## Impact - -- Provider failures can appear as completed assistant messages and dispatch success-shaped hooks. -- Persisted `runOutcome` / `runStopReason` can disagree with the visible terminal block. -- Session-end hooks and deterministic evaluations lose the actual stop reason and cumulative usage. -- Operational telemetry cannot distinguish a complete answer from a truncated or bounded run. - -## Root Cause - -`processStream` handles only context-window errors before its common success finalizer. Other error -events fall through to `finalize()`. The common finalizer also stamps `complete` after loop exits -without preserving why the loop stopped, and the context-window return omits fields already present -in persisted metadata. - -## Fix Plan - -- Route every provider `error` stop through the error finalizer, retaining context-window handling. -- Preserve explicit `max_tokens` and `max_tool_calls` reasons instead of overwriting them with - `complete`. -- Return usage, stop reason, and error message for context-window failures. -- Use the same canonical provider/tool/empty stop reason in persisted metadata, `ProcessResult`, and - terminal hooks. -- Mark tool calls rejected by the 128-call budget as unexecuted errors before finalizing the message. -- Reject permission-approved deferred execution before invocation when persisted accounting has - already reached the 128-call budget, without incrementing beyond the cap. -- Give `processStream` sole ownership of terminal persistence after streaming starts; lifecycle code - owns only pre-stream cancellation/error settlement and includes run identity and zero-count - accounting there. -- Add process-level and deterministic evaluation coverage for each terminal path. -- Keep existing visible error-block and open-plan terminal behavior compatible. - -## Tasks - -- [x] Normalize generic provider error outcomes. -- [x] Preserve max-token and tool-call-limit stop reasons. -- [x] Return complete context-window failure metadata. -- [x] Add focused process/evaluation regression scenarios. -- [x] Align metadata, process results, and terminal hook stop reasons. -- [x] Mark budget-skipped tool blocks and remove double stream-abort settlement. -- [x] Enforce the same tool-call budget on deferred permission execution. -- [x] Persist complete pre-stream error/abort run metadata. -- [ ] Run final repository quality gates. - -## Validation - -- A generic provider error returns and persists `error`, never `completed`. -- `max_tokens` and the 128-call guard retain distinct stop reasons. -- Context-window errors return the same usage and reason persisted in message metadata. -- Terminal hooks receive the normalized reason and usage. -- A tool call that exceeds the hard cap is never finalized as a successful execution. -- A granted deferred tool at the hard cap does not invoke the tool and keeps `toolCalls` at 128. -- Stream abort writes one terminal message; pre-stream abort remains lifecycle-owned. - -## GitHub - -No GitHub issue sync was requested. diff --git a/src/main/agent/acp/instance/acpAgentInstance.ts b/src/main/agent/acp/instance/acpAgentInstance.ts index a45cd250c0..3bbaeb5d23 100644 --- a/src/main/agent/acp/instance/acpAgentInstance.ts +++ b/src/main/agent/acp/instance/acpAgentInstance.ts @@ -117,7 +117,7 @@ export class AcpAgentInstance }) } - async send(content: string | SendMessageInput): Promise { + async send(content: SendMessageInput): Promise { if (this.closed) throw new Error(`ACP session ${this.sessionId} is closed`) if (this.active) throw new Error(`ACP session ${this.sessionId} is already generating`) diff --git a/src/main/agent/acp/instance/acpAgentRuntime.ts b/src/main/agent/acp/instance/acpAgentRuntime.ts index ca64f80353..97ca680bf4 100644 --- a/src/main/agent/acp/instance/acpAgentRuntime.ts +++ b/src/main/agent/acp/instance/acpAgentRuntime.ts @@ -160,7 +160,7 @@ export class AcpAgentRuntime { async send( input: AcpAgentRuntimeSessionInput, - content: string | SendMessageInput + content: SendMessageInput ): Promise { const instance = await this.getOrHydrate(input) this.assertAccepting() @@ -245,7 +245,7 @@ export class AcpAgentRuntime { async queuePendingInput( input: AcpAgentRuntimeSessionInput, - content: string | SendMessageInput + content: SendMessageInput ): Promise { const instance = await this.getOrHydrate(input) this.assertAccepting() @@ -271,7 +271,7 @@ export class AcpAgentRuntime { async steer( input: AcpAgentRuntimeSessionInput, - content: string | SendMessageInput + content: SendMessageInput ): Promise { const instance = await this.getOrHydrate(input) this.assertAccepting() @@ -291,7 +291,7 @@ export class AcpAgentRuntime { updateQueuedInput( sessionId: AppSessionId, itemId: string, - content: string | SendMessageInput + content: SendMessageInput ): PendingSessionInputRecord { return this.requirePendingInputs().updateQueuedInput(sessionId, itemId, content) } diff --git a/src/main/agent/acp/instance/ports.ts b/src/main/agent/acp/instance/ports.ts index fba27ef48c..0aae7ac327 100644 --- a/src/main/agent/acp/instance/ports.ts +++ b/src/main/agent/acp/instance/ports.ts @@ -37,7 +37,7 @@ export interface AcpAgentSnapshot { export interface AcpAgentSessionHandle { readonly kind: 'acp' readonly sessionId: AppSessionId - send(content: string | SendMessageInput): Promise + send(content: SendMessageInput): Promise cancel(): Promise snapshot(): Promise waitForFirstTurnReady(options?: { timeoutMs?: number }): Promise @@ -111,18 +111,18 @@ export interface AcpPendingInputFacet { listPendingInputs(sessionId: AppSessionId): PendingSessionInputRecord[] queuePendingInput( sessionId: AppSessionId, - input: string | SendMessageInput, + input: SendMessageInput, options?: { state?: PendingSessionInputState } ): PendingSessionInputRecord queueSteerInput( sessionId: AppSessionId, - input: string | SendMessageInput, + input: SendMessageInput, options?: { mergeItemId?: string | null } ): PendingSessionInputRecord updateQueuedInput( sessionId: AppSessionId, itemId: string, - input: string | SendMessageInput + input: SendMessageInput ): PendingSessionInputRecord moveQueuedInput( sessionId: AppSessionId, @@ -179,7 +179,7 @@ export interface AcpPromptResourcePort { agent: AcpAgentConfig scope: AcpInstanceScope workdir: string - content: string | SendMessageInput + content: SendMessageInput signal: AbortSignal }): Promise } @@ -224,7 +224,10 @@ export interface AcpViewManifestInput { } export type AcpProjectionSettlement = - | { status: 'completed'; stopReason: 'complete' } + | { + status: 'completed' + stopReason: 'complete' | 'max_tokens' | 'max_turn_requests' + } | { status: 'error'; stopReason: 'error'; errorMessage: string } | { status: 'aborted'; stopReason: 'user_stop'; errorMessage: string } diff --git a/src/main/agent/acp/runtime/acpContentMapper.ts b/src/main/agent/acp/runtime/acpContentMapper.ts index 70373fc3c7..0404caacf9 100644 --- a/src/main/agent/acp/runtime/acpContentMapper.ts +++ b/src/main/agent/acp/runtime/acpContentMapper.ts @@ -5,20 +5,26 @@ import { normalizeAgentPlanStatus } from '@shared/types/agent-plan' import { createStreamEvent, type LLMCoreStreamEvent } from '@shared/types/core/llm-events' import { normalizeAcpConfigState } from './acpConfigState' -export function mapAcpPromptStopReason( +export function createAcpPromptTerminalEvents( reason: schema.PromptResponse['stopReason'] -): 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error' | 'complete' { +): LLMCoreStreamEvent[] { switch (reason) { case 'max_tokens': - return 'max_tokens' + return [createStreamEvent.stop('max_tokens')] case 'max_turn_requests': - return 'stop_sequence' + return [createStreamEvent.stop('max_turn_requests')] case 'cancelled': + return [ + createStreamEvent.error('ACP prompt was cancelled by the agent.'), + createStreamEvent.stop('error') + ] case 'refusal': - return 'error' + return [ + createStreamEvent.error('ACP agent refused the prompt.'), + createStreamEvent.stop('error') + ] case 'end_turn': - default: - return 'complete' + return [createStreamEvent.stop('complete')] } } diff --git a/src/main/agent/acp/runtime/index.ts b/src/main/agent/acp/runtime/index.ts index 31116f7dd9..42d2e39eff 100644 --- a/src/main/agent/acp/runtime/index.ts +++ b/src/main/agent/acp/runtime/index.ts @@ -16,7 +16,7 @@ export { type AcpCapabilitySnapshot } from './acpCapabilities' export { AcpMessageFormatter } from './acpMessageFormatter' -export { AcpContentMapper, mapAcpPromptStopReason } from './acpContentMapper' +export { AcpContentMapper, createAcpPromptTerminalEvents } from './acpContentMapper' export { AcpCompatibilityPromptBuilder } from './acpCompatibilityPromptBuilder' export { AcpSessionController, diff --git a/src/main/agent/deepchat/deepChatAgentRepository.ts b/src/main/agent/deepchat/deepChatAgentRepository.ts index 433f332447..05c022f0c3 100644 --- a/src/main/agent/deepchat/deepChatAgentRepository.ts +++ b/src/main/agent/deepchat/deepChatAgentRepository.ts @@ -68,7 +68,7 @@ const mergeDeepChatConfig = ( overrideConfig.imageGenerationModel ?? baseConfig.imageGenerationModel ?? null, defaultProjectPath: overrideConfig.defaultProjectPath ?? baseConfig.defaultProjectPath ?? null, systemPrompt: overrideConfig.systemPrompt ?? baseConfig.systemPrompt ?? '', - permissionMode: overrideConfig.permissionMode ?? baseConfig.permissionMode ?? 'full_access', + permissionMode: overrideConfig.permissionMode ?? baseConfig.permissionMode, disabledAgentTools: overrideConfig.disabledAgentTools ?? baseConfig.disabledAgentTools ?? [], enabledSkillNames: mergeNullableStringList( baseConfig.enabledSkillNames, diff --git a/src/main/agent/deepchat/pending/pendingInputCoordinator.ts b/src/main/agent/deepchat/pending/pendingInputCoordinator.ts index 94bf199232..49f41f780d 100644 --- a/src/main/agent/deepchat/pending/pendingInputCoordinator.ts +++ b/src/main/agent/deepchat/pending/pendingInputCoordinator.ts @@ -8,30 +8,6 @@ import { DeepChatPendingInputStore } from './pendingInputStore' const MAX_ACTIVE_PENDING_INPUTS = 5 -function normalizeInput(input: string | SendMessageInput): SendMessageInput { - if (typeof input === 'string') { - return { text: input, files: [] } - } - - const activeSkills = Array.isArray(input?.activeSkills) - ? Array.from( - new Set( - input.activeSkills - .map((skillName) => (typeof skillName === 'string' ? skillName.trim() : '')) - .filter((skillName) => skillName.length > 0) - ) - ) - : [] - - const inlineItems = Array.isArray(input?.inlineItems) ? input.inlineItems : [] - return { - text: typeof input?.text === 'string' ? input.text : '', - files: Array.isArray(input?.files) ? input.files.filter(Boolean) : [], - ...(activeSkills.length > 0 ? { activeSkills } : {}), - ...(inlineItems.length > 0 ? { inlineItems } : {}) - } -} - export class PendingInputCoordinator { private readonly store: DeepChatPendingInputStore @@ -45,7 +21,7 @@ export class PendingInputCoordinator { queuePendingInput( sessionId: string, - input: string | SendMessageInput, + input: SendMessageInput, options?: { state?: PendingSessionInputState } @@ -53,7 +29,7 @@ export class PendingInputCoordinator { this.ensureWithinLimit(sessionId) const record = this.store.createQueueInputWithState( sessionId, - normalizeInput(input), + input, options?.state ?? 'pending' ) this.emitUpdated(sessionId) @@ -62,16 +38,16 @@ export class PendingInputCoordinator { queueSteerInput( sessionId: string, - input: string | SendMessageInput, + input: SendMessageInput, options?: { mergeItemId?: string | null } ): PendingSessionInputRecord { let record: PendingSessionInputRecord if (options?.mergeItemId) { - record = this.store.appendSteerInput(options.mergeItemId, normalizeInput(input)) + record = this.store.appendSteerInput(options.mergeItemId, input) } else { - record = this.store.createSteerInput(sessionId, normalizeInput(input)) + record = this.store.createSteerInput(sessionId, input) } this.emitUpdated(sessionId) return record @@ -80,10 +56,10 @@ export class PendingInputCoordinator { updateQueuedInput( sessionId: string, itemId: string, - input: string | SendMessageInput + input: SendMessageInput ): PendingSessionInputRecord { this.assertQueueInput(sessionId, itemId) - const record = this.store.updateQueueInput(itemId, normalizeInput(input)) + const record = this.store.updateQueueInput(itemId, input) this.emitUpdated(sessionId) return record } diff --git a/src/main/agent/deepchat/pending/pendingInputStore.ts b/src/main/agent/deepchat/pending/pendingInputStore.ts index d5d0d9e13e..1982c3d585 100644 --- a/src/main/agent/deepchat/pending/pendingInputStore.ts +++ b/src/main/agent/deepchat/pending/pendingInputStore.ts @@ -4,35 +4,13 @@ import type { PendingSessionInputState, SendMessageInput } from '@shared/types/agent-interface' +import { SendMessageInputSchema } from '@shared/contracts/common' +import { normalizeSendMessageInput } from '@/agent/shared/agentSessionNormalization' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' import type { DeepChatPendingInputRow } from '@/presenter/sqlitePresenter/tables/deepchatPendingInputs' type InlineItem = NonNullable[number] -function normalizeInput(input: string | SendMessageInput): SendMessageInput { - if (typeof input === 'string') { - return { text: input, files: [] } - } - - const activeSkills = Array.isArray(input?.activeSkills) - ? Array.from( - new Set( - input.activeSkills - .map((skillName) => (typeof skillName === 'string' ? skillName.trim() : '')) - .filter((skillName) => skillName.length > 0) - ) - ) - : [] - - const inlineItems = Array.isArray(input?.inlineItems) ? input.inlineItems : [] - return { - text: typeof input?.text === 'string' ? input.text : '', - files: Array.isArray(input?.files) ? input.files.filter(Boolean) : [], - ...(activeSkills.length > 0 ? { activeSkills } : {}), - ...(inlineItems.length > 0 ? { inlineItems } : {}) - } -} - function shiftInlineItems( inlineItems: SendMessageInput['inlineItems'], offset: number @@ -76,16 +54,15 @@ export class DeepChatPendingInputStore { return row ? this.toRecord(row) : null } - createQueueInput(sessionId: string, input: string | SendMessageInput): PendingSessionInputRecord { + createQueueInput(sessionId: string, input: SendMessageInput): PendingSessionInputRecord { return this.createQueueInputWithState(sessionId, input, 'pending') } createQueueInputWithState( sessionId: string, - input: string | SendMessageInput, + input: SendMessageInput, state: PendingSessionInputState ): PendingSessionInputRecord { - const normalized = normalizeInput(input) const id = nanoid() const nextQueueOrder = this.getNextQueueOrder(sessionId) const claimedAt = state === 'claimed' ? Date.now() : null @@ -94,7 +71,7 @@ export class DeepChatPendingInputStore { sessionId, mode: 'queue', state, - payloadJson: JSON.stringify(normalized), + payloadJson: JSON.stringify(input), queueOrder: nextQueueOrder, claimedAt }) @@ -105,15 +82,14 @@ export class DeepChatPendingInputStore { return this.toRecord(row) } - createSteerInput(sessionId: string, input: string | SendMessageInput): PendingSessionInputRecord { - const normalized = normalizeInput(input) + createSteerInput(sessionId: string, input: SendMessageInput): PendingSessionInputRecord { const id = nanoid() this.sqlitePresenter.deepchatPendingInputsTable.insert({ id, sessionId, mode: 'steer', state: 'pending', - payloadJson: JSON.stringify(normalized), + payloadJson: JSON.stringify(input), queueOrder: null, claimedAt: null }) @@ -124,7 +100,7 @@ export class DeepChatPendingInputStore { return this.toRecord(row) } - appendSteerInput(itemId: string, input: string | SendMessageInput): PendingSessionInputRecord { + appendSteerInput(itemId: string, input: SendMessageInput): PendingSessionInputRecord { const row = this.requireRow(itemId) if (row.mode !== 'steer') { throw new Error(`Pending input ${itemId} is not a steer item.`) @@ -133,8 +109,8 @@ export class DeepChatPendingInputStore { throw new Error(`Pending steer item ${itemId} is not editable.`) } - const existing = this.parsePayload(row.payload_json) - const next = normalizeInput(input) + const existing = this.decodePayload(row) + const next = input const existingText = existing.text.trim() const nextText = next.text.trim() const separator = existingText && nextText ? '\n\n' : '' @@ -159,10 +135,10 @@ export class DeepChatPendingInputStore { return this.toRecord(this.requireRow(itemId, row.session_id)) } - updateQueueInput(itemId: string, input: string | SendMessageInput): PendingSessionInputRecord { + updateQueueInput(itemId: string, input: SendMessageInput): PendingSessionInputRecord { const row = this.requireRow(itemId) this.sqlitePresenter.deepchatPendingInputsTable.update(itemId, { - payload_json: JSON.stringify(normalizeInput(input)) + payload_json: JSON.stringify(input) }) return this.toRecord(this.requireRow(itemId, row.session_id)) } @@ -393,7 +369,7 @@ export class DeepChatPendingInputStore { sessionId: row.session_id, mode: row.mode, state: row.state as PendingSessionInputState, - payload: this.parsePayload(row.payload_json), + payload: this.decodePayload(row), queueOrder: row.queue_order, claimedAt: row.claimed_at, consumedAt: row.consumed_at, @@ -402,11 +378,19 @@ export class DeepChatPendingInputStore { } } - private parsePayload(raw: string): SendMessageInput { + private decodePayload(row: DeepChatPendingInputRow): SendMessageInput { + let parsed: unknown try { - return normalizeInput(JSON.parse(raw) as SendMessageInput) + parsed = JSON.parse(row.payload_json) } catch { - return normalizeInput(raw) + throw new Error(`Invalid pending input payload JSON: ${row.id}`) + } + + const result = SendMessageInputSchema.safeParse(parsed) + if (!result.success) { + throw new Error(`Invalid pending input payload shape: ${row.id}`) } + + return normalizeSendMessageInput(result.data) } } diff --git a/src/main/agent/manager/deepChatAgentBackend.ts b/src/main/agent/manager/deepChatAgentBackend.ts index 504eb9dfd7..93ae995a0a 100644 --- a/src/main/agent/manager/deepChatAgentBackend.ts +++ b/src/main/agent/manager/deepChatAgentBackend.ts @@ -35,7 +35,7 @@ export interface DeepChatAgentBackendPort { waitForFirstTurnReady(sessionId: AppSessionId, options?: { timeoutMs?: number }): Promise processMessage( sessionId: AppSessionId, - content: string | SendMessageInput, + content: SendMessageInput, context?: { projectDir?: string | null emitRefreshBeforeStream?: boolean @@ -45,17 +45,17 @@ export interface DeepChatAgentBackendPort { } ): Promise cancelGeneration(sessionId: AppSessionId): Promise - steerActiveTurn(sessionId: AppSessionId, content: string | SendMessageInput): Promise + steerActiveTurn(sessionId: AppSessionId, content: SendMessageInput): Promise listPendingInputs(sessionId: AppSessionId): Promise queuePendingInput( sessionId: AppSessionId, - content: string | SendMessageInput, + content: SendMessageInput, options?: QueuePendingInputOptions ): Promise updateQueuedInput( sessionId: AppSessionId, itemId: string, - content: string | SendMessageInput + content: SendMessageInput ): Promise moveQueuedInput( sessionId: AppSessionId, diff --git a/src/main/agent/manager/sessionHandles.ts b/src/main/agent/manager/sessionHandles.ts index 7602745d39..fc69fdfd67 100644 --- a/src/main/agent/manager/sessionHandles.ts +++ b/src/main/agent/manager/sessionHandles.ts @@ -25,13 +25,13 @@ export interface AgentSessionLifecycleFacet { } export interface AgentPendingInputFacet { - steerActiveTurn(content: string | SendMessageInput): Promise + steerActiveTurn(content: SendMessageInput): Promise list(): Promise queue( - content: string | SendMessageInput, + content: SendMessageInput, options?: { source: 'queue' | 'send'; projectDir?: string | null } ): Promise - update(itemId: string, content: string | SendMessageInput): Promise + update(itemId: string, content: SendMessageInput): Promise move(itemId: string, toIndex: number): Promise convertToSteer(itemId: string): Promise steer(itemId: string): Promise diff --git a/src/main/agent/shared/agentSessionHandle.ts b/src/main/agent/shared/agentSessionHandle.ts index 42935acf45..96cdb6f835 100644 --- a/src/main/agent/shared/agentSessionHandle.ts +++ b/src/main/agent/shared/agentSessionHandle.ts @@ -1,7 +1,7 @@ import type { PendingInputEnqueueSource, SendMessageInput } from '@shared/types/agent-interface' export interface AgentSessionSendInput { - content: string | SendMessageInput + content: SendMessageInput context?: { projectDir?: string | null emitRefreshBeforeStream?: boolean diff --git a/src/main/presenter/agentRuntimePresenter/accumulator.ts b/src/main/presenter/agentRuntimePresenter/accumulator.ts index b2a9cf90f4..b4772eae2b 100644 --- a/src/main/presenter/agentRuntimePresenter/accumulator.ts +++ b/src/main/presenter/agentRuntimePresenter/accumulator.ts @@ -256,7 +256,7 @@ export function accumulate(state: StreamState, event: LLMCoreStreamEvent): void // Keep an explicit stream error terminal reason even if the provider later emits a // generic complete stop after the error event. if (state.stopReason !== 'error') { - state.stopReason = mapStopReason(event.stop_reason) + state.stopReason = event.stop_reason } break } @@ -280,16 +280,3 @@ export function accumulate(state: StreamState, event: LLMCoreStreamEvent): void break } } - -function mapStopReason(reason: string): 'complete' | 'tool_use' | 'error' | 'abort' | 'max_tokens' { - switch (reason) { - case 'tool_use': - return 'tool_use' - case 'max_tokens': - return 'max_tokens' - case 'error': - return 'error' - default: - return 'complete' - } -} diff --git a/src/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.ts b/src/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.ts index 44a2c71d50..62c6722fed 100644 --- a/src/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.ts +++ b/src/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.ts @@ -16,7 +16,7 @@ import { markStreamingProviderPermissionResolved, resolveProviderTerminalDecision } from './process' -import { mapAcpPromptStopReason } from '@/agent/acp/runtime/acpContentMapper' +import { createAcpPromptTerminalEvents } from '@/agent/acp/runtime/acpContentMapper' import { createState, type IoParams, type StreamState } from './types' import { DeepChatMessageStore } from './messageStore' import { DeepChatTapeService } from './tapeService' @@ -134,7 +134,7 @@ export class AcpCompatibilityProjectionAdapter implements AcpCompatibilityProjec stopReason: Parameters[1] ): AcpProjectionSettlement { const state = this.takeState(handle.messageId) - accumulate(state.stream, createStreamEvent.stop(mapAcpPromptStopReason(stopReason))) + createAcpPromptTerminalEvents(stopReason).forEach((event) => accumulate(state.stream, event)) return this.settleTerminal(state) } @@ -172,7 +172,7 @@ export class AcpCompatibilityProjectionAdapter implements AcpCompatibilityProjec return { status: 'error', stopReason: 'error', errorMessage: decision.error } } finalize(state.stream, state.io) - return { status: 'completed', stopReason: 'complete' } + return { status: 'completed', stopReason: decision.stopReason } } private requireState(messageId: string): ProjectionState { diff --git a/src/main/presenter/agentRuntimePresenter/acpCompatibilityDependencies.ts b/src/main/presenter/agentRuntimePresenter/acpCompatibilityDependencies.ts index f9daff1998..8ae8d86be7 100644 --- a/src/main/presenter/agentRuntimePresenter/acpCompatibilityDependencies.ts +++ b/src/main/presenter/agentRuntimePresenter/acpCompatibilityDependencies.ts @@ -14,7 +14,6 @@ import type { DeepChatAgentInstance } from '@/agent/deepchat/instance/deepChatAg import { awaitWithAbort } from '@/lib/awaitWithAbort' import { capAgentRequestMaxTokens, estimateToolReserveTokens } from './contextBudget' import { createUserChatMessage } from './contextBuilder' -import { normalizeUserMessageInput } from './interactionProjection' import { resolveEffectiveActiveSkillNames } from '@/agent/deepchat/resources/systemPromptBuilder' import type { DeepChatSessionStore } from './sessionStore' import type { DeepChatMessageStore } from './messageStore' @@ -96,8 +95,7 @@ export function createAcpCompatibilityDependencies( dependencies.getGenerationSettings(sessionId, resourceInstance), signal ) - const normalizedInput = normalizeUserMessageInput(content) - resourceInstance.replaceRuntimeActivatedSkills(normalizedInput.activeSkills ?? []) + resourceInstance.replaceRuntimeActivatedSkills(content.activeSkills ?? []) let tools: MCPToolDefinition[] = [] let systemPrompt = '' @@ -142,19 +140,15 @@ export function createAcpCompatibilityDependencies( const summaryCursorOrderSeq = dependencies.sessionStore.getSummaryState(sessionId).summaryCursorOrderSeq return { - latestUserMessage: createUserChatMessage(normalizedInput, false, false), + latestUserMessage: createUserChatMessage(content, false, false), userContent: { - text: normalizedInput.text, - files: normalizedInput.files ?? [], + text: content.text, + files: content.files ?? [], links: [], search: false, think: false, - ...(normalizedInput.activeSkills?.length - ? { activeSkills: normalizedInput.activeSkills } - : {}), - ...(normalizedInput.inlineItems?.length - ? { inlineItems: normalizedInput.inlineItems } - : {}) + ...(content.activeSkills?.length ? { activeSkills: content.activeSkills } : {}), + ...(content.inlineItems?.length ? { inlineItems: content.inlineItems } : {}) }, sections: { configured: systemPrompt, diff --git a/src/main/presenter/agentRuntimePresenter/compactionService.ts b/src/main/presenter/agentRuntimePresenter/compactionService.ts index 31731f6deb..b843c44b56 100644 --- a/src/main/presenter/agentRuntimePresenter/compactionService.ts +++ b/src/main/presenter/agentRuntimePresenter/compactionService.ts @@ -331,7 +331,7 @@ export class CompactionService { supportsAudioInput?: boolean preserveInterleavedReasoning: boolean preserveEmptyInterleavedReasoning?: boolean - newUserContent: string | SendMessageInput + newUserContent: SendMessageInput historyRecords?: ChatMessageRecord[] signal?: AbortSignal }): Promise { diff --git a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts index 6e48f7d27f..0a2b8776d9 100644 --- a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts +++ b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts @@ -551,14 +551,13 @@ export function buildUserMessageContent( } export function createUserChatMessage( - input: string | SendMessageInput, + input: SendMessageInput, supportsVision: boolean, supportsAudioInput: boolean = false ): ChatMessage { - const normalizedInput = normalizeUserInput(input) return { role: 'user', - content: buildUserMessageContent(normalizedInput, supportsVision, supportsAudioInput, { + content: buildUserMessageContent(input, supportsVision, supportsAudioInput, { includeImageData: true, includeAudioData: true, includeFileContent: false @@ -1094,9 +1093,11 @@ export function buildContext( supportsVision: boolean = false, options: ContextBuildOptions = {} ): ChatMessage[] { + const input = + typeof newUserContent === 'string' ? { text: newUserContent, files: [] } : newUserContent return buildContextWithMetadata( sessionId, - newUserContent, + input, systemPrompt, contextLength, reserveTokens, @@ -1108,7 +1109,7 @@ export function buildContext( export function buildContextWithMetadata( sessionId: string, - newUserContent: string | SendMessageInput, + newUserContent: SendMessageInput, systemPrompt: string, contextLength: number, reserveTokens: number, diff --git a/src/main/presenter/agentRuntimePresenter/deferredToolExecutor.ts b/src/main/presenter/agentRuntimePresenter/deferredToolExecutor.ts index 88ff5bb90b..484b8d4628 100644 --- a/src/main/presenter/agentRuntimePresenter/deferredToolExecutor.ts +++ b/src/main/presenter/agentRuntimePresenter/deferredToolExecutor.ts @@ -1,8 +1,4 @@ -import type { - AssistantMessageBlock, - DeepChatSessionState, - PermissionMode -} from '@shared/types/agent-interface' +import type { AssistantMessageBlock, DeepChatSessionState } from '@shared/types/agent-interface' import type { MCPToolCall, MCPToolResponse, ToolCallImagePreview } from '@shared/types/core/mcp' import type { ToolExecutionPort, ToolResultPort } from '@/agent/deepchat/loop/ports' import { awaitWithAbort } from '@/lib/awaitWithAbort' @@ -37,7 +33,6 @@ export interface DeferredToolExecutorDependencies { getAbortSignal(sessionId: string): AbortSignal | undefined resolveProjectDir(sessionId: string): string | null getSessionState(sessionId: string): Promise - getRuntimeState(sessionId: string): DeepChatSessionState | undefined getSessionAgentId(sessionId: string): string | undefined updateSubagentProgress( sessionId: string, @@ -49,10 +44,6 @@ export interface DeferredToolExecutorDependencies { ): void } -function normalizePermissionMode(mode: PermissionMode | null | undefined): PermissionMode { - return mode === 'auto_approve' || mode === 'full_access' ? mode : 'default' -} - function throwIfAbortRequested(signal?: AbortSignal): void { if (!signal?.aborted) return if (typeof DOMException !== 'undefined') { @@ -97,10 +88,6 @@ export class DeferredToolExecutor { try { throwIfAbortRequested(deferredAbortSignal) const projectDir = this.dependencies.resolveProjectDir(sessionId) - const sessionState = await awaitWithAbort( - this.dependencies.getSessionState(sessionId), - deferredAbortSignal - ) const toolDefinitions = await awaitWithAbort( this.dependencies.toolResolver.loadToolDefinitionsForSession(sessionId, projectDir), deferredAbortSignal @@ -127,36 +114,42 @@ export class DeferredToolExecutor { } } - const request: MCPToolCall = { - id: toolCall.id || '', - type: 'function', - function: { - name: toolName, - arguments: toolCall.params || '{}' - }, - server: toolDefinition.server, - conversationId: sessionId, - providerId: sessionState?.providerId?.trim() || undefined - } - const extensionPolicy = await awaitWithAbort( this.dependencies.toolResolver.resolveAgentExtensionPolicy(sessionId), deferredAbortSignal ) throwIfAbortRequested(deferredAbortSignal) - const deferredPermissionMode = normalizePermissionMode( - this.dependencies.getRuntimeState(sessionId)?.permissionMode - ) const deferredActiveSkillNames = await awaitWithAbort( this.dependencies.toolResolver.resolveActiveSkillNamesForToolProfile(sessionId), deferredAbortSignal ) throwIfAbortRequested(deferredAbortSignal) + const sessionState = await awaitWithAbort( + this.dependencies.getSessionState(sessionId), + deferredAbortSignal + ) + if (!sessionState) { + return { + responseText: `Session '${sessionId}' is no longer available.`, + isError: true + } + } + const request: MCPToolCall = { + id: toolCall.id || '', + type: 'function', + function: { + name: toolName, + arguments: toolCall.params || '{}' + }, + server: toolDefinition.server, + conversationId: sessionId, + providerId: sessionState.providerId.trim() || undefined + } invoked = true onToolCallStarted?.() const result = await this.dependencies.toolExecutionPort.execute(request, { agentId: this.dependencies.getSessionAgentId(sessionId) ?? 'deepchat', - permissionMode: deferredPermissionMode, + permissionMode: sessionState.permissionMode, activeSkillNames: deferredActiveSkillNames, enabledSkillNames: extensionPolicy.enabledSkillNames ?? undefined, enabledMcpServerIds: this.dependencies.toolResolver.toToolDefinitionMcpServerIds( diff --git a/src/main/presenter/agentRuntimePresenter/generationSettings.ts b/src/main/presenter/agentRuntimePresenter/generationSettings.ts index 0b4301ed2a..9abd66bcc5 100644 --- a/src/main/presenter/agentRuntimePresenter/generationSettings.ts +++ b/src/main/presenter/agentRuntimePresenter/generationSettings.ts @@ -38,7 +38,7 @@ import type { InterleavedReasoningConfig } from './types' export type PersistedSessionGenerationRow = { provider_id: string model_id: string - permission_mode: PermissionMode | null + permission_mode: PermissionMode system_prompt: string | null temperature: number | null top_p: number | null diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 03c22f6535..5ec4274295 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -98,7 +98,7 @@ import { } from './toolAdapters' import { DeepChatToolResolver } from './toolResolver' import { DeferredToolExecutor, type DeferredToolExecutionResult } from './deferredToolExecutor' -import { normalizePermissionMode, SessionSettingsCoordinator } from './sessionSettingsCoordinator' +import { SessionSettingsCoordinator } from './sessionSettingsCoordinator' import { CompactionRuntimeCoordinator } from './compactionRuntimeCoordinator' import { ProviderPermissionCoordinator } from './providerPermissionCoordinator' import { InteractionCoordinator, type ResumeBudgetToolCall } from './interactionCoordinator' @@ -132,7 +132,6 @@ import { buildEditedUserContent, collectPendingInteractionEntries, extractUserMessageInput, - normalizeUserMessageInput, parseAssistantBlocks, reconcilePendingInteractionEntries, replacePendingInteractions, @@ -412,7 +411,6 @@ export class AgentRuntimePresenter { getAbortSignal: (sessionId) => this.getAbortSignalForSession(sessionId), resolveProjectDir: (sessionId) => this.resolveProjectDir(sessionId), getSessionState: async (sessionId) => await this.getSessionState(sessionId), - getRuntimeState: (sessionId) => this.getDeepChatRuntimeState(sessionId), getSessionAgentId: (sessionId) => this.getSessionAgentId(sessionId), updateSubagentProgress: (...args) => this.updateSubagentToolCallProgress(...args) }) @@ -740,7 +738,7 @@ export class AgentRuntimePresenter { } ): Promise { const projectDir = this.normalizeProjectDir(config.projectDir) - const permissionMode = normalizePermissionMode(config.permissionMode) + const permissionMode = config.permissionMode ?? 'default' logger.info( `[DeepChatAgent] initSession id=${sessionId} provider=${config.providerId} model=${config.modelId} permission=${permissionMode} hasProjectDir=${projectDir !== null}` ) @@ -828,7 +826,7 @@ export class AgentRuntimePresenter { status: 'idle', providerId: dbSession.provider_id, modelId: dbSession.model_id, - permissionMode: normalizePermissionMode(dbSession.permission_mode) + permissionMode: dbSession.permission_mode } instance.setRuntimeState(rebuilt) if (hydrationMode === 'full') { @@ -872,15 +870,15 @@ export class AgentRuntimePresenter { options && Object.prototype.hasOwnProperty.call(options, 'projectDir') ? this.resolveProjectDir(sessionId, options.projectDir) : this.resolveProjectDir(sessionId) - const normalizedInput = normalizeUserMessageInput(content) - if (!normalizedInput.text.trim() && (normalizedInput.files?.length ?? 0) === 0) { + const input = typeof content === 'string' ? { text: content, files: [] } : content + if (!input.text.trim() && (input.files?.length ?? 0) === 0) { throw new Error('Message cannot be empty.') } const shouldClaimImmediately = ((options?.source ?? 'send') === 'send' && this.isAwaitingToolQuestionFollowUp(sessionId)) || this.shouldStartQueuedInputImmediately(sessionId, state.status) - const record = this.pendingInputCoordinator.queuePendingInput(sessionId, content, { + const record = this.pendingInputCoordinator.queuePendingInput(sessionId, input, { state: shouldClaimImmediately ? 'claimed' : 'pending' }) @@ -906,8 +904,8 @@ export class AgentRuntimePresenter { throw new Error('Please resolve pending tool interactions before steering.') } - const normalizedInput = normalizeUserMessageInput(content) - if (!normalizedInput.text.trim() && (normalizedInput.files?.length ?? 0) === 0) { + const input = typeof content === 'string' ? { text: content, files: [] } : content + if (!input.text.trim() && (input.files?.length ?? 0) === 0) { return } @@ -918,7 +916,7 @@ export class AgentRuntimePresenter { if (activeGeneration) { // Enqueue the steer input first (it sorts ahead of queued items, and rapid successive steers // merge into the same pending record), then interrupt the active stream. - this.queueVisibleSteerInput(sessionId, normalizedInput) + this.queueVisibleSteerInput(sessionId, input) // A stream is actively producing tokens: interrupt it while preserving its partial output. // The abort settlement auto-drains the queue and runs the steer input as the next turn. await this.cancelGeneration(sessionId) @@ -926,7 +924,7 @@ export class AgentRuntimePresenter { } if (preStreamController) { - this.queueVisibleSteerInput(sessionId, normalizedInput) + this.queueVisibleSteerInput(sessionId, input) // The current turn is still in pre-stream setup (no tokens yet, user message not persisted). // Don't abort — let it finish; the steer input drains right after as the next visible turn. return @@ -934,13 +932,13 @@ export class AgentRuntimePresenter { if (!this.canStartPendingQueueDrain(sessionId, state.status, 'enqueue')) { if (instance?.isPendingQueueDraining() || state.status === 'generating') { - this.queueVisibleSteerInput(sessionId, normalizedInput) + this.queueVisibleSteerInput(sessionId, input) return } throw new Error('Unable to start the steered input.') } - const record = this.queueVisibleSteerInput(sessionId, normalizedInput) + const record = this.queueVisibleSteerInput(sessionId, input) const started = await this.drainPendingQueueIfPossible(sessionId, 'enqueue') if (started) { return @@ -966,7 +964,8 @@ export class AgentRuntimePresenter { content: string | SendMessageInput ): Promise { await this.ensureSessionReadyForPendingInputMutation(sessionId) - return this.pendingInputCoordinator.updateQueuedInput(sessionId, itemId, content) + const input = typeof content === 'string' ? { text: content, files: [] } : content + return this.pendingInputCoordinator.updateQueuedInput(sessionId, itemId, input) } async moveQueuedInput( @@ -1043,7 +1042,8 @@ export class AgentRuntimePresenter { content: string | SendMessageInput, context?: TurnStartContext ): Promise { - return await this.turnCoordinator.start(sessionId, content, context) + const input = typeof content === 'string' ? { text: content, files: [] } : content + return await this.turnCoordinator.start(sessionId, input, context) } private logSlowPreStreamStep(sessionId: string, step: string, startedAt: number): void { const elapsed = Date.now() - startedAt diff --git a/src/main/presenter/agentRuntimePresenter/process.ts b/src/main/presenter/agentRuntimePresenter/process.ts index b0455d0b08..5cc0862daf 100644 --- a/src/main/presenter/agentRuntimePresenter/process.ts +++ b/src/main/presenter/agentRuntimePresenter/process.ts @@ -36,6 +36,10 @@ import { buildTapeToolFactInputs } from './tapeFacts' const UNKNOWN_CONTEXT_LIMIT = Number.MAX_SAFE_INTEGER const USER_CANCELED_GENERATION_ERROR = 'common.error.userCanceledGeneration' export const NO_MODEL_RESPONSE_ERROR = 'common.error.noModelResponse' +export const INCOMPLETE_PROVIDER_STREAM_ERROR = + 'Provider stream ended without a terminal stop event.' +export const INCOMPLETE_TOOL_USE_ERROR = + 'Provider requested tool use without a completed tool call.' const deepChatLoopEngine = new DeepChatLoopEngine() type PendingPermissionPayload = NonNullable type PendingPermissionCommandInfo = NonNullable @@ -117,7 +121,10 @@ function markUnexecutedToolCallsForLimit(state: StreamState): void { } export type ProviderTerminalDecision = - | { type: 'complete'; stopReason: 'complete' | 'max_tokens' | 'max_tool_calls' } + | { + type: 'complete' + stopReason: 'complete' | 'max_tokens' | 'max_turn_requests' + } | { type: 'error' error: string @@ -126,7 +133,8 @@ export type ProviderTerminalDecision = } export function resolveProviderTerminalDecision(state: StreamState): ProviderTerminalDecision { - if (state.stopReason === 'error') { + const stopReason = state.stopReason + if (stopReason === 'error') { const streamErrorMessage = getLatestErrorMessage(state) ?? NO_MODEL_RESPONSE_ERROR if (isContextWindowErrorLike(streamErrorMessage)) { stripTrailingErrorBlock(state, streamErrorMessage) @@ -144,6 +152,14 @@ export function resolveProviderTerminalDecision(state: StreamState): ProviderTer stopReason: 'provider_error' } } + if (stopReason === null) { + return { + type: 'error', + error: INCOMPLETE_PROVIDER_STREAM_ERROR, + source: 'provider', + stopReason: 'provider_error' + } + } if (state.blocks.length === 0 && !state.latestAgentPlanSnapshot) { return { type: 'error', @@ -152,10 +168,21 @@ export function resolveProviderTerminalDecision(state: StreamState): ProviderTer stopReason: 'empty_response' } } - if (state.stopReason === 'max_tokens' || state.stopReason === 'max_tool_calls') { - return { type: 'complete', stopReason: state.stopReason } + switch (stopReason) { + case 'complete': + case 'max_tokens': + case 'max_turn_requests': + return { type: 'complete', stopReason } + case 'tool_use': + return { + type: 'error', + error: INCOMPLETE_TOOL_USE_ERROR, + source: 'provider', + stopReason: 'provider_error' + } } - return { type: 'complete', stopReason: 'complete' } + const unsupportedStopReason: never = stopReason + throw new Error(`Unsupported provider stop reason: ${String(unsupportedStopReason)}`) } function parseAssistantBlocks(rawContent: string): AssistantMessageBlock[] { @@ -536,7 +563,6 @@ function settleLoopOutcome( logger.info( `[ProcessStream] max tool calls reached (${outcome.attemptedToolCount} > ${outcome.limit}), stopping` ) - state.stopReason = 'max_tool_calls' state.planTerminalReason = 'max_steps' markUnexecutedToolCallsForLimit(state) } @@ -551,6 +577,21 @@ function settleLoopOutcome( usage: buildUsageSnapshot(state) } } + if (outcome.type === 'max_tool_calls') { + stampRunOutcome(state, 'completed', 'max_tool_calls') + outputSink.complete({ + runId: run.runId, + sessionId: run.sessionId, + messageId: run.messageId, + blocks: state.blocks, + metadata: { ...state.metadata } + }) + return { + status: 'completed', + stopReason: 'max_tool_calls', + usage: buildUsageSnapshot(state) + } + } const terminalDecision = resolveProviderTerminalDecision(state) if (terminalDecision.type === 'error') { stampRunOutcome(state, 'error', terminalDecision.stopReason) @@ -796,6 +837,7 @@ export async function processStream(params: ProcessParams): Promise { - const normalizedMode = normalizePermissionMode(mode) const state = this.deps.getRuntimeState(sessionId) - this.deps.sessionStore.updatePermissionMode(sessionId, normalizedMode) + this.deps.sessionStore.updatePermissionMode(sessionId, mode) if (state) { - state.permissionMode = normalizedMode + state.permissionMode = mode } } @@ -63,6 +58,10 @@ export class SessionSettingsCoordinator { if (!state && !dbSession) { throw new Error(`Session ${sessionId} not found`) } + const permissionMode = state?.permissionMode ?? dbSession?.permission_mode + if (!permissionMode) { + throw new Error(`Session ${sessionId} permission mode is missing`) + } if (state?.status === 'generating') { throw new Error('Cannot switch model while session is generating.') } @@ -90,7 +89,7 @@ export class SessionSettingsCoordinator { status: 'idle', providerId: nextProviderId, modelId: nextModelId, - permissionMode: normalizePermissionMode(dbSession?.permission_mode) + permissionMode }) } instance.setGenerationSettings(sanitized) @@ -114,7 +113,7 @@ export class SessionSettingsCoordinator { throw new Error('Cannot move session while it is generating.') } - const permissionMode = normalizePermissionMode(config.permissionMode) + const permissionMode = config.permissionMode const generationSettings = await sanitizeGenerationSettings( this.deps.configPresenter, nextProviderId, @@ -178,7 +177,7 @@ export class SessionSettingsCoordinator { if (state) { return state.permissionMode } - return normalizePermissionMode(this.deps.sessionStore.get(sessionId)?.permission_mode) + return this.deps.sessionStore.get(sessionId)?.permission_mode ?? 'default' } async getGenerationSettings(sessionId: string): Promise { diff --git a/src/main/presenter/agentRuntimePresenter/sessionStore.ts b/src/main/presenter/agentRuntimePresenter/sessionStore.ts index 3e4fc04550..4f7d0238b3 100644 --- a/src/main/presenter/agentRuntimePresenter/sessionStore.ts +++ b/src/main/presenter/agentRuntimePresenter/sessionStore.ts @@ -140,7 +140,7 @@ export class DeepChatSessionStore { id: string, providerId: string, modelId: string, - permissionMode: PermissionMode = 'full_access', + permissionMode: PermissionMode, generationSettings?: Partial ): void { this.sqlitePresenter.deepchatSessionsTable.create( diff --git a/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts b/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts index 038d73d27d..f69acf2a0b 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts @@ -20,7 +20,7 @@ export type TapeViewPolicySelectionReason = export interface TapeChatViewPolicyInput { sessionId: string - newUserContent: string | SendMessageInput + newUserContent: SendMessageInput systemPrompt: string contextLength: number reserveTokens: number diff --git a/src/main/presenter/agentRuntimePresenter/turnCoordinator.ts b/src/main/presenter/agentRuntimePresenter/turnCoordinator.ts index 19a1fe87ef..07342ce674 100644 --- a/src/main/presenter/agentRuntimePresenter/turnCoordinator.ts +++ b/src/main/presenter/agentRuntimePresenter/turnCoordinator.ts @@ -33,11 +33,7 @@ import type { CompactionRuntimeCoordinator } from './compactionRuntimeCoordinato import type { CompactionService } from './compactionService' import { isContextWindowErrorLike } from './contextWindowError' import { resolveInterleavedReasoningConfig } from './generationSettings' -import { - updateToolCallResponse, - normalizeUserMessageInput, - parseAssistantBlocks -} from './interactionProjection' +import { updateToolCallResponse, parseAssistantBlocks } from './interactionProjection' import { buildTerminalErrorBlocks, type DeepChatMessageStore } from './messageStore' import type { ProcessResult } from './types' import { buildUsageFromMetadata, stampTerminalMetadata } from './runtimeMetadata' @@ -279,7 +275,7 @@ export class TurnCoordinator { async start( sessionId: string, - content: string | SendMessageInput, + content: SendMessageInput, context?: { projectDir?: string | null emitRefreshBeforeStream?: boolean @@ -296,15 +292,14 @@ export class TurnCoordinator { throw new Error('Pending tool interactions must be resolved before sending a new message.') } - const normalizedInput = normalizeUserMessageInput(content) - if (!normalizedInput.text.trim() && (normalizedInput.files?.length ?? 0) === 0) { + if (!content.text.trim() && (content.files?.length ?? 0) === 0) { throw new Error('Message cannot be empty.') } const supportsVision = this.ports.supportsVision(state.providerId, state.modelId) const supportsAudioInput = this.ports.supportsAudioInput(state.providerId, state.modelId) const projectDir = this.ports.resolveProjectDir(sessionId, context?.projectDir, instance) logger.info( - `[DeepChatAgent] processMessage session=${sessionId} promptLength=${normalizedInput.text.length} fileCount=${normalizedInput.files?.length ?? 0} hasProjectDir=${projectDir !== null}` + `[DeepChatAgent] processMessage session=${sessionId} promptLength=${content.text.length} fileCount=${content.files?.length ?? 0} hasProjectDir=${projectDir !== null}` ) this.ports.setSessionStatus(sessionId, 'generating') @@ -335,18 +330,16 @@ export class TurnCoordinator { instance, signal: preStreamAbortSignal, projectDir, - runtimeActivatedSkillNames: normalizedInput.activeSkills ?? [] + runtimeActivatedSkillNames: content.activeSkills ?? [] }) const userContent: UserMessageContent = { - text: normalizedInput.text, - files: normalizedInput.files || [], + text: content.text, + files: content.files || [], links: [], search: false, think: false, - ...(normalizedInput.activeSkills?.length - ? { activeSkills: normalizedInput.activeSkills } - : {}), - ...(normalizedInput.inlineItems?.length ? { inlineItems: normalizedInput.inlineItems } : {}) + ...(content.activeSkills?.length ? { activeSkills: content.activeSkills } : {}), + ...(content.inlineItems?.length ? { inlineItems: content.inlineItems } : {}) } const preparedInput = await this.ports.inputPreparationCoordinator.prepareInitial({ @@ -382,7 +375,7 @@ export class TurnCoordinator { preserveInterleavedReasoning: interleavedReasoning.preserveReasoningContent, preserveEmptyInterleavedReasoning: interleavedReasoning.preserveEmptyReasoningContent === true, - newUserContent: normalizedInput, + newUserContent: content, historyRecords, signal: preStreamAbortSignal }) @@ -457,7 +450,7 @@ export class TurnCoordinator { this.ports.dispatchHook('UserPromptSubmit', { sessionId, messageId: userMessageId, - promptPreview: normalizedInput.text, + promptPreview: content.text, providerId: state.providerId, modelId: state.modelId, projectDir @@ -480,7 +473,7 @@ export class TurnCoordinator { summaryText: summaryState.summaryText, reconstructionAnchor: this.ports.sessionStore.getReconstructionAnchorPromptState(sessionId), - memoryQuery: normalizedInput.text, + memoryQuery: content.text, memoryMessageId: userMessageId }), preStreamAbortSignal @@ -491,7 +484,7 @@ export class TurnCoordinator { const contextBuildStartedAt = Date.now() const contextBuild = buildTapeChatView({ sessionId, - newUserContent: normalizedInput, + newUserContent: content, systemPrompt, contextLength: contextBudgetLength, reserveTokens: maxTokens, @@ -551,7 +544,7 @@ export class TurnCoordinator { messageId: assistantMessageId, messages, projectDir, - promptPreview: normalizedInput.text, + promptPreview: content.text, tools, baseSystemPrompt, resourceInstance: instance, @@ -570,7 +563,7 @@ export class TurnCoordinator { summaryText: summaryState.summaryText, reconstructionAnchor: this.ports.sessionStore.getReconstructionAnchorPromptState(sessionId), - memoryQuery: normalizedInput.text, + memoryQuery: content.text, memoryMessageId: userMessageId }) }, diff --git a/src/main/presenter/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index b3a63a28ef..8265d9e666 100644 --- a/src/main/presenter/agentRuntimePresenter/types.ts +++ b/src/main/presenter/agentRuntimePresenter/types.ts @@ -4,7 +4,7 @@ import type { PermissionMode, QuestionOption } from '@shared/types/agent-interface' -import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' +import type { LLMCoreStreamEvent, ProviderRoundStopReason } from '@shared/types/core/llm-events' import type { ChatMessage, ChatMessageProviderOptions } from '@shared/types/core/chat-message' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/presenter' @@ -55,7 +55,7 @@ export interface StreamState { } > completedToolCalls: ToolCallResult[] - stopReason: 'complete' | 'tool_use' | 'error' | 'abort' | 'max_tokens' | 'max_tool_calls' + stopReason: ProviderRoundStopReason | null latestAgentPlanSnapshot?: AgentPlanSnapshot planTerminalReason?: AgentPlanTerminalReason roundUsage: { @@ -245,7 +245,7 @@ export function createState(): StreamState { firstTokenTime: null, pendingToolCalls: new Map(), completedToolCalls: [], - stopReason: 'complete', + stopReason: null, roundUsage: null, providerRoundCount: 0, toolCallCount: 0, diff --git a/src/main/presenter/llmProviderPresenter/aiSdk/streamAdapter.ts b/src/main/presenter/llmProviderPresenter/aiSdk/streamAdapter.ts index 328484b6db..a821cfe766 100644 --- a/src/main/presenter/llmProviderPresenter/aiSdk/streamAdapter.ts +++ b/src/main/presenter/llmProviderPresenter/aiSdk/streamAdapter.ts @@ -18,26 +18,6 @@ function resolveSafeTextLength(buffer: string): number { return buffer.length } -function mapFinishReason( - reason: string | undefined -): 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error' | 'complete' { - switch (reason) { - case 'tool-calls': - case 'tool_calls': - case 'tool-call': - case 'tool_use': - return 'tool_use' - case 'length': - return 'max_tokens' - case 'error': - return 'error' - case 'stop': - return 'stop_sequence' - default: - return 'complete' - } -} - function toUsageEvent(usage: { inputTokens?: number outputTokens?: number @@ -226,16 +206,48 @@ export async function* adaptAiSdkStream( break } - case 'finish': + case 'finish': { if (!options.supportsNativeTools) { yield* emitLegacyTextBuffer(true) } yield toUsageEvent(part.totalUsage) - yield createStreamEvent.stop( - !options.supportsNativeTools && legacyToolUseDetected - ? 'tool_use' - : mapFinishReason(part.finishReason) - ) + if (!options.supportsNativeTools && legacyToolUseDetected && part.finishReason === 'stop') { + yield createStreamEvent.stop('tool_use') + break + } + switch (part.finishReason) { + case 'stop': + yield createStreamEvent.stop('complete') + break + case 'length': + yield createStreamEvent.stop('max_tokens') + break + case 'tool-calls': + yield createStreamEvent.stop('tool_use') + break + case 'content-filter': + yield createStreamEvent.error( + 'Provider stopped the response because of content filtering.' + ) + yield createStreamEvent.stop('error') + break + case 'error': + yield createStreamEvent.error('Provider stopped the response because of an error.') + yield createStreamEvent.stop('error') + break + case 'other': + yield createStreamEvent.error( + `Provider stopped the response for an unspecified reason${part.rawFinishReason ? `: ${part.rawFinishReason}` : '.'}` + ) + yield createStreamEvent.stop('error') + break + } + break + } + + case 'abort': + yield createStreamEvent.error(part.reason?.trim() || 'Provider stream aborted.') + yield createStreamEvent.stop('error') break case 'error': diff --git a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts index 06a389521e..433e87d390 100644 --- a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts @@ -31,7 +31,7 @@ import { AcpProcessManager, AcpSessionManager, AcpSessionPersistence, - mapAcpPromptStopReason, + createAcpPromptTerminalEvents, AcpMessageFormatter, type AcpProcessHandle, type AcpSessionRecord @@ -1159,7 +1159,7 @@ export class AcpProvider extends BaseLLMProvider { completedAt: completedTurn.completedAt ?? Date.now() }) } - queue.push(createStreamEvent.stop(mapAcpPromptStopReason(response.stopReason))) + createAcpPromptTerminalEvents(response.stopReason).forEach((event) => queue.push(event)) } catch (error) { if (timeoutMs && error instanceof Error && error.name === 'AbortError') { try { diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index 45bf1599ad..7926937da5 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -19,8 +19,8 @@ import { normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' -const normalizePermissionMode = (mode: PermissionMode | null | undefined): PermissionMode => - mode === 'default' || mode === 'auto_approve' ? mode : 'full_access' +const resolveAssignmentPermissionMode = (mode?: PermissionMode | null): PermissionMode => + mode ?? 'full_access' export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort { constructor( @@ -59,10 +59,9 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort providerId, modelId, projectDir, - permissionMode: - input.permissionMode !== undefined - ? normalizePermissionMode(input.permissionMode) - : normalizePermissionMode(agentConfig?.permissionMode), + permissionMode: resolveAssignmentPermissionMode( + input.permissionMode ?? agentConfig?.permissionMode + ), generationSettings: this.mergeDefaultGenerationSettings( agentConfig, input.generationSettings @@ -88,7 +87,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort } return { agentId: descriptor.id, - permissionMode: normalizePermissionMode(permissionMode) + permissionMode: resolveAssignmentPermissionMode(permissionMode) } } @@ -109,7 +108,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null, providerId: 'acp', modelId: descriptor.id, - permissionMode: normalizePermissionMode(input.permissionMode), + permissionMode: resolveAssignmentPermissionMode(input.permissionMode), generationSettings: { systemPrompt: '' }, disabledAgentTools: [], activeSkills: [] @@ -128,7 +127,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort targetAgentId, providerId: input.providerId, modelId: input.modelId, - permissionMode: normalizePermissionMode(input.permissionMode), + permissionMode: resolveAssignmentPermissionMode(input.permissionMode), generationSettings: input.generationSettings, disabledAgentTools: normalizeDisabledAgentTools(input.disabledAgentTools), activeSkills: normalizeActiveSkills(input.activeSkills) @@ -151,7 +150,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort targetAgentId, providerId: input.providerId, modelId: input.modelId, - permissionMode: normalizePermissionMode(agentConfig?.permissionMode), + permissionMode: resolveAssignmentPermissionMode(agentConfig?.permissionMode), generationSettings, disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), activeSkills: this.filterSkillsByAllowList( @@ -197,7 +196,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort agentConfig?.defaultProjectPath?.trim() || this.config.getDefaultProjectPath()?.trim() || null, - permissionMode: normalizePermissionMode(agentConfig?.permissionMode), + permissionMode: resolveAssignmentPermissionMode(agentConfig?.permissionMode), generationSettings: this.mergeDefaultGenerationSettings(agentConfig), disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), subagentEnabled: agentConfig?.subagentEnabled === true diff --git a/src/main/presenter/sessionApplication/turnCoordinator.ts b/src/main/presenter/sessionApplication/turnCoordinator.ts index 27ccbecd29..cf2613f70e 100644 --- a/src/main/presenter/sessionApplication/turnCoordinator.ts +++ b/src/main/presenter/sessionApplication/turnCoordinator.ts @@ -32,7 +32,7 @@ export class SessionTurnCoordinator implements SessionTurnPort, SessionInitialTu constructor(private readonly dependencies: SessionTurnCoordinatorDependencies) {} startInitialTurn(input: SessionInitialTurnInput): void { - const content = normalizeSendMessageInput(input.content) + const content = input.content if (!content.text.trim() && (content.files?.length ?? 0) === 0) return try { diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts index 13f97ed88c..262c379b33 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts @@ -68,8 +68,8 @@ interface RawDeepChatSessionRow extends Omit ): void { this.db @@ -394,7 +394,7 @@ export class DeepChatSessionsTable extends BaseTable { return row ? { ...row, - permission_mode: normalizePersistedPermissionMode(row.permission_mode) + permission_mode: decodePersistedPermissionMode(row.permission_mode) } : undefined } diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index d555fdc5c7..673802c19e 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -5,8 +5,7 @@ import type { AgentTransferImpact, AgentTapeContextResult, MessageTraceRecord, - PendingSessionInputRecord, - SendMessageInput + PendingSessionInputRecord } from '@shared/types/agent-interface' import type { DeepChatTapeReplaySlice } from '@shared/types/tape-replay' import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' @@ -20,6 +19,7 @@ import { MessageFileSchema, UserMessageInlineItemSchema, PermissionModeSchema, + SendMessageInputSchema, SessionCompactionStateSchema, SessionGenerationSettingsSchema, SessionGenerationSettingsPatchSchema, @@ -212,7 +212,7 @@ export const sessionsListPendingInputsRoute = defineRouteContract({ }) }) -const PendingInputPayloadSchema = z.union([z.string(), z.custom()]) +const PendingInputPayloadSchema = z.union([z.string(), SendMessageInputSchema]) export const sessionsQueuePendingInputRoute = defineRouteContract({ name: 'sessions.queuePendingInput', diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 456a09e3f4..b3e4fb7d2c 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -126,7 +126,7 @@ export interface SessionAgentContextUpdate { providerId: string modelId: string projectDir?: string | null - permissionMode?: PermissionMode + permissionMode: PermissionMode generationSettings?: Partial } diff --git a/src/shared/types/core/llm-events.ts b/src/shared/types/core/llm-events.ts index 6b44f2df86..cb07b2de12 100644 --- a/src/shared/types/core/llm-events.ts +++ b/src/shared/types/core/llm-events.ts @@ -73,9 +73,16 @@ export interface UsageStreamEvent { } } +export type ProviderRoundStopReason = + | 'tool_use' + | 'max_tokens' + | 'max_turn_requests' + | 'error' + | 'complete' + export interface StopStreamEvent { type: 'stop' - stop_reason: 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error' | 'complete' + stop_reason: ProviderRoundStopReason } export interface ImageDataStreamEvent { @@ -206,9 +213,7 @@ export const createStreamEvent = { type: 'usage', usage }), - stop: ( - stop_reason: 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error' | 'complete' - ): StopStreamEvent => ({ + stop: (stop_reason: ProviderRoundStopReason): StopStreamEvent => ({ type: 'stop', stop_reason }), diff --git a/test/main/agent/acp/instance/acpAgentRuntime.test.ts b/test/main/agent/acp/instance/acpAgentRuntime.test.ts index 354850d42b..15b363141c 100644 --- a/test/main/agent/acp/instance/acpAgentRuntime.test.ts +++ b/test/main/agent/acp/instance/acpAgentRuntime.test.ts @@ -614,8 +614,8 @@ describe('AcpAgentRuntime', () => { const harness = createHarness({ pendingInputs }) const input = createInput() - await harness.runtime.queuePendingInput(input, 'first') - await harness.runtime.queuePendingInput(input, 'second') + await harness.runtime.queuePendingInput(input, { text: 'first', files: [] }) + await harness.runtime.queuePendingInput(input, { text: 'second', files: [] }) await vi.waitFor(() => expect(harness.connection.prompt).toHaveBeenCalledTimes(2)) expect(pendingInputs.queuedStates[0]).toBe('claimed') @@ -830,7 +830,10 @@ describe('AcpAgentRuntime', () => { const input = createInput() const active = harness.runtime.send(input, 'active') await vi.waitFor(() => expect(harness.connection.prompt).toHaveBeenCalledTimes(1)) - const queued = pendingInputs.queuePendingInput(input.sessionId, 'promoted steer') + const queued = pendingInputs.queuePendingInput(input.sessionId, { + text: 'promoted steer', + files: [] + }) await expect( harness.runtime.steerPendingInput(input.sessionId, queued.id) @@ -893,7 +896,7 @@ describe('AcpAgentRuntime', () => { const harness = createHarness({ pendingInputs, resourceRejects: true }) const input = createInput() - const record = await harness.runtime.queuePendingInput(input, 'retry me') + const record = await harness.runtime.queuePendingInput(input, { text: 'retry me', files: [] }) await vi.waitFor(async () => { expect(await harness.runtime.getHydrated(input.sessionId)?.snapshot()).toMatchObject({ diff --git a/test/main/agent/acp/runtime/acpContentMapper.test.ts b/test/main/agent/acp/runtime/acpContentMapper.test.ts index 2d89351ef5..e8280b8a22 100644 --- a/test/main/agent/acp/runtime/acpContentMapper.test.ts +++ b/test/main/agent/acp/runtime/acpContentMapper.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' import { AcpContentMapper, - mapAcpPromptStopReason + createAcpPromptTerminalEvents } from '@/agent/acp/runtime/acpContentMapper' const createNotification = ( @@ -15,13 +15,25 @@ const createNotification = ( describe('ACP prompt stop reason mapping', () => { it.each([ - ['end_turn', 'complete'], - ['max_tokens', 'max_tokens'], - ['max_turn_requests', 'stop_sequence'], - ['cancelled', 'error'], - ['refusal', 'error'] - ] as const)('maps %s to %s', (input, expected) => { - expect(mapAcpPromptStopReason(input)).toBe(expected) + ['end_turn', [{ type: 'stop', stop_reason: 'complete' }]], + ['max_tokens', [{ type: 'stop', stop_reason: 'max_tokens' }]], + ['max_turn_requests', [{ type: 'stop', stop_reason: 'max_turn_requests' }]], + [ + 'cancelled', + [ + { type: 'error', error_message: 'ACP prompt was cancelled by the agent.' }, + { type: 'stop', stop_reason: 'error' } + ] + ], + [ + 'refusal', + [ + { type: 'error', error_message: 'ACP agent refused the prompt.' }, + { type: 'stop', stop_reason: 'error' } + ] + ] + ] as const)('maps %s to its complete terminal event sequence', (input, expected) => { + expect(createAcpPromptTerminalEvents(input)).toEqual(expected) }) }) diff --git a/test/main/agent/deepchat/pending/pendingInputStore.test.ts b/test/main/agent/deepchat/pending/pendingInputStore.test.ts index 5c5c4e8740..28a37b1190 100644 --- a/test/main/agent/deepchat/pending/pendingInputStore.test.ts +++ b/test/main/agent/deepchat/pending/pendingInputStore.test.ts @@ -93,7 +93,7 @@ describe('DeepChatPendingInputStore', () => { createQueueRow('claimed-1', 'session-1', 1, 'claimed') ]) - const record = store.createQueueInput('session-1', 'hello') + const record = store.createQueueInput('session-1', { text: 'hello', files: [] }) expect(record.queueOrder).toBe(2) expect(deepchatPendingInputsTable.insert).toHaveBeenCalledWith( @@ -113,7 +113,11 @@ describe('DeepChatPendingInputStore', () => { createQueueRow('claimed-2', 'session-1', 2, 'claimed') ]) - const record = store.createQueueInputWithState('session-1', 'hello', 'claimed') + const record = store.createQueueInputWithState( + 'session-1', + { text: 'hello', files: [] }, + 'claimed' + ) expect(record.queueOrder).toBe(3) expect(deepchatPendingInputsTable.insert).toHaveBeenCalledWith( @@ -125,4 +129,42 @@ describe('DeepChatPendingInputStore', () => { }) ) }) + + it('persists the supplied canonical payload without rewriting it', () => { + vi.mocked(nanoid).mockReturnValue('canonical-input') + const { store, deepchatPendingInputsTable } = createStore([]) + const input = { + text: 'hello', + files: [], + activeSkills: ['review'] + } + + store.createQueueInput('session-1', input) + + expect(deepchatPendingInputsTable.insert).toHaveBeenCalledWith( + expect.objectContaining({ payloadJson: JSON.stringify(input) }) + ) + }) + + it('decodes the original text-and-files payload format', () => { + const row = createQueueRow('legacy-1', 'session-1', 1, 'pending') + row.payload_json = JSON.stringify({ text: 'legacy', files: [] }) + const { store } = createStore([row]) + + expect(store.getInput('legacy-1')?.payload).toEqual({ text: 'legacy', files: [] }) + }) + + it.each([ + ['invalid JSON', 'not-json', 'JSON'], + ['JSON string', JSON.stringify('legacy text'), 'shape'], + ['invalid object', JSON.stringify({ files: [] }), 'shape'] + ])('rejects %s instead of converting it to user text', (_label, payloadJson, errorKind) => { + const row = createQueueRow('corrupt-1', 'session-1', 1, 'pending') + row.payload_json = payloadJson + const { store } = createStore([row]) + + expect(() => store.getInput('corrupt-1')).toThrow( + `Invalid pending input payload ${errorKind}: corrupt-1` + ) + }) }) diff --git a/test/main/agent/manager/deepChatAgentBackend.test.ts b/test/main/agent/manager/deepChatAgentBackend.test.ts index 2b736430de..7c0fd84136 100644 --- a/test/main/agent/manager/deepChatAgentBackend.test.ts +++ b/test/main/agent/manager/deepChatAgentBackend.test.ts @@ -54,18 +54,25 @@ describe('DeepChatAgentBackend', () => { const port = createPort() const handle = createDeepChatAgentBackendFixture(port).open(toAppSessionId('session')) - expect(await handle.send({ content: 'direct' })).toEqual({ + expect(await handle.send({ content: { text: 'direct', files: [] } })).toEqual({ requestId: 'request', messageId: 'message' }) expect( - await handle.send({ content: 'queued', queue: { source: 'send', projectDir: '/tmp' } }) + await handle.send({ + content: { text: 'queued', files: [] }, + queue: { source: 'send', projectDir: '/tmp' } + }) ).toEqual({ requestId: null, messageId: null }) expect(port.processMessage).toHaveBeenCalledTimes(1) - expect(port.queuePendingInput).toHaveBeenCalledWith('session', 'queued', { - source: 'send', - projectDir: '/tmp' - }) + expect(port.queuePendingInput).toHaveBeenCalledWith( + 'session', + { text: 'queued', files: [] }, + { + source: 'send', + projectDir: '/tmp' + } + ) }) it('uses lightweight snapshots and delegates cancel and close exactly once', async () => { @@ -93,8 +100,10 @@ describe('DeepChatAgentBackend', () => { await deepchat.transferSource.hasMessages(parent) await deepchat.transferSource.listPendingInputs(parent) await deepchat.transferTarget.setSessionAgentContext(parent, { + agentId: 'deepchat', providerId: 'openai', - modelId: 'model' + modelId: 'model', + permissionMode: 'full_access' }) await deepchat.subagent.mergeTape(parent, child, { outcome: 'merged' }) await deepchat.subagent.discardTape(parent, child, { outcome: 'discarded' }) @@ -107,8 +116,10 @@ describe('DeepChatAgentBackend', () => { expect(port.hasMessages).toHaveBeenCalledWith('parent') expect(port.listPendingInputs).toHaveBeenCalledWith('parent') expect(port.setSessionAgentContext).toHaveBeenCalledWith('parent', { + agentId: 'deepchat', providerId: 'openai', - modelId: 'model' + modelId: 'model', + permissionMode: 'full_access' }) expect(port.mergeSubagentTape).toHaveBeenCalledWith('parent', 'child', { outcome: 'merged' diff --git a/test/main/agent/manager/directAcpAgentBackend.test.ts b/test/main/agent/manager/directAcpAgentBackend.test.ts index 73f3e95470..eb80c7fc5f 100644 --- a/test/main/agent/manager/directAcpAgentBackend.test.ts +++ b/test/main/agent/manager/directAcpAgentBackend.test.ts @@ -125,7 +125,7 @@ describe('direct ACP agent backend', () => { const handle = harness.backend.open(sessionId, descriptor) await handle.lifecycle.initialize({ providerId: 'acp', modelId: descriptor.id }) - await expect(handle.send({ content: 'hello' })).resolves.toEqual({ + await expect(handle.send({ content: { text: 'hello', files: [] } })).resolves.toEqual({ requestId: 'request', messageId: 'message' }) @@ -137,7 +137,7 @@ describe('direct ACP agent backend', () => { }) expect(harness.runtime.send).toHaveBeenCalledWith( expect.objectContaining({ sessionId, descriptor }), - 'hello' + { text: 'hello', files: [] } ) }) diff --git a/test/main/presenter/agentRepository.test.ts b/test/main/presenter/agentRepository.test.ts index 93f14519a4..7561a45763 100644 --- a/test/main/presenter/agentRepository.test.ts +++ b/test/main/presenter/agentRepository.test.ts @@ -76,10 +76,9 @@ describe('AgentRepository', () => { config: null }) expect(repository.getDeepChatAgentConfig('broken-deepchat')).toBeNull() - expect(repository.resolveDeepChatAgentConfig('broken-deepchat')).toMatchObject({ - systemPrompt: 'Builtin prompt', - permissionMode: 'full_access' - }) + const resolvedConfig = repository.resolveDeepChatAgentConfig('broken-deepchat') + expect(resolvedConfig).toMatchObject({ systemPrompt: 'Builtin prompt' }) + expect(resolvedConfig.permissionMode).toBeUndefined() expect(repository.listResolvedDeepChatAgentConfigs()).toEqual([ expect.objectContaining({ agentId: 'deepchat', diff --git a/test/main/presenter/agentRuntimePresenter/accumulator.test.ts b/test/main/presenter/agentRuntimePresenter/accumulator.test.ts index 906c995158..b586958e61 100644 --- a/test/main/presenter/agentRuntimePresenter/accumulator.test.ts +++ b/test/main/presenter/agentRuntimePresenter/accumulator.test.ts @@ -300,6 +300,9 @@ describe('accumulate', () => { expect(state.blocks[1].content).toBe('Rate limit') expect(state.blocks[1].status).toBe('error') expect(state.stopReason).toBe('error') + + accumulate(state, { type: 'stop', stop_reason: 'complete' }) + expect(state.stopReason).toBe('error') }) it('sets dirty flag on block mutations', () => { diff --git a/test/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.test.ts b/test/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.test.ts index a751de65b1..e55f722a03 100644 --- a/test/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.test.ts +++ b/test/main/presenter/agentRuntimePresenter/acpCompatibilityAdapters.test.ts @@ -250,6 +250,32 @@ describe('ACP compatibility adapters', () => { ) }) + it.each([ + ['max_tokens', 'max_tokens'], + ['max_turn_requests', 'max_turn_requests'] + ] as const)('preserves the ACP %s stop reason through settlement', (stopReason, expected) => { + const harness = createProjectionHarness() + harness.adapter.applyEvents(harness.handle, [{ type: 'text', content: 'partial response' }]) + + expect(harness.adapter.complete(harness.handle, stopReason)).toEqual({ + status: 'completed', + stopReason: expected + }) + }) + + it.each([ + ['refusal', 'ACP agent refused the prompt.'], + ['cancelled', 'ACP prompt was cancelled by the agent.'] + ] as const)('surfaces ACP %s as an explicit provider error', (stopReason, errorMessage) => { + const harness = createProjectionHarness() + + expect(harness.adapter.complete(harness.handle, stopReason)).toEqual({ + status: 'error', + stopReason: 'error', + errorMessage + }) + }) + it('persists ACP tool responses and appends a Tape tool result', () => { const harness = createProjectionHarness() const mapper = new AcpContentMapper() diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 12378110b9..e78cf09c2e 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -541,7 +541,7 @@ function createMockSqlitePresenter() { function createMockCoreStream() { return async function* () { yield { type: 'text', content: 'Hello' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } } } @@ -5724,7 +5724,11 @@ describe('AgentRuntimePresenter', () => { projectDir: '/tmp/workspace' }) - expect(queueSpy).toHaveBeenCalledWith('s1', 'Hello', { state: 'claimed' }) + expect(queueSpy).toHaveBeenCalledWith( + 's1', + { text: 'Hello', files: [] }, + { state: 'claimed' } + ) expect(processSpy).toHaveBeenCalledWith( 's1', claimedRecord.payload, @@ -5760,7 +5764,11 @@ describe('AgentRuntimePresenter', () => { const result = await agent.queuePendingInput('s1', 'Queued later', { source: 'queue' }) - expect(queueSpy).toHaveBeenCalledWith('s1', 'Queued later', { state: 'pending' }) + expect(queueSpy).toHaveBeenCalledWith( + 's1', + { text: 'Queued later', files: [] }, + { state: 'pending' } + ) expect(processSpy).not.toHaveBeenCalled() expect(result).toBe(pendingRecord) }) @@ -5773,8 +5781,14 @@ describe('AgentRuntimePresenter', () => { ;(nanoid as ReturnType) .mockReturnValueOnce('queued-1') .mockReturnValueOnce('queued-2') - ;(agent as any).pendingInputCoordinator.queuePendingInput('s1', 'First queued') - ;(agent as any).pendingInputCoordinator.queuePendingInput('s1', 'Second queued') + ;(agent as any).pendingInputCoordinator.queuePendingInput('s1', { + text: 'First queued', + files: [] + }) + ;(agent as any).pendingInputCoordinator.queuePendingInput('s1', { + text: 'Second queued', + files: [] + }) let resolveStreamStarted: () => void = () => {} const streamStarted = new Promise((resolve) => { @@ -10148,17 +10162,6 @@ describe('AgentRuntimePresenter', () => { expect(mode).toBe('default') }) - it('normalizes an unknown persisted permission mode to default', async () => { - sqlitePresenter.deepchatSessionsTable.get.mockReturnValue({ - id: 's2', - provider_id: 'openai', - model_id: 'gpt-4', - permission_mode: 'unknown' - }) - - await expect(agent.getPermissionMode('s2')).resolves.toBe('default') - }) - it('falls back to ask_user when auto-review returns invalid JSON', async () => { llmProvider.generateCompletionStandalone.mockResolvedValueOnce('not json') @@ -10537,7 +10540,7 @@ describe('AgentRuntimePresenter', () => { } }) - it('passes providerId when executing a deferred MCP tool call', async () => { + it('passes provider and hydrated permission mode to deferred MCP tool calls', async () => { toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ { type: 'function', @@ -10554,7 +10557,11 @@ describe('AgentRuntimePresenter', () => { rawData: { toolCallId: 'tc1', content: 'tool result', isError: false } }) - await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.initSession('s1', { + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'auto_approve' + }) await (agent as any).executeDeferredToolCall('s1', 'm1', { id: 'tc1', @@ -10568,11 +10575,52 @@ describe('AgentRuntimePresenter', () => { providerId: 'openai' }), expect.objectContaining({ + permissionMode: 'auto_approve', signal: expect.any(Object) }) ) }) + it('uses the latest permission mode when deferred tool preparation is still running', async () => { + const toolDefinitions = deferred() + toolPresenter.getAllToolDefinitions.mockReturnValueOnce(toolDefinitions.promise) + toolPresenter.callTool.mockResolvedValueOnce({ + content: 'tool result', + rawData: { toolCallId: 'tc1', content: 'tool result', isError: false } + }) + + await agent.initSession('s1', { + providerId: 'openai', + modelId: 'gpt-4', + permissionMode: 'full_access' + }) + + const execution = (agent as any).executeDeferredToolCall('s1', 'm1', { + id: 'tc1', + name: 'echo', + params: '{}' + }) + await vi.waitFor(() => expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalled()) + await agent.setPermissionMode('s1', 'default') + toolDefinitions.resolve([ + { + type: 'function', + function: { + name: 'echo', + description: 'Echo tool', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'test-server', icons: '', description: '' } + } + ]) + await execution + + expect(toolPresenter.callTool).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ permissionMode: 'default' }) + ) + }) + it('registers a cancellable controller for deferred subagent tool calls', async () => { toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ { diff --git a/test/main/presenter/agentRuntimePresenter/compactionService.test.ts b/test/main/presenter/agentRuntimePresenter/compactionService.test.ts index cad9efa2e6..21e9eb7c95 100644 --- a/test/main/presenter/agentRuntimePresenter/compactionService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/compactionService.test.ts @@ -243,7 +243,7 @@ describe('CompactionService', () => { reserveTokens: 256, supportsVision: true, preserveInterleavedReasoning: false, - newUserContent: 'Next turn' + newUserContent: { text: 'Next turn', files: [] } }) expect(intent).not.toBeNull() @@ -279,7 +279,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) expect(intent).toBeNull() @@ -335,7 +335,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) const manualIntent = await service.prepareForManualCompaction({ sessionId: 's1', @@ -404,7 +404,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) sessionConfig.autoCompactionTriggerThreshold = 80 @@ -417,7 +417,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) expect(noIntentAtFullBudget).toBeNull() @@ -448,7 +448,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) expect(intent).not.toBeNull() @@ -478,7 +478,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'next turn' + newUserContent: { text: 'next turn', files: [] } }) await service.prepareForNextUserTurn({ sessionId: 's1', @@ -489,7 +489,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: true, - newUserContent: 'next turn' + newUserContent: { text: 'next turn', files: [] } }) expect(buildHistoryTurns).toHaveBeenNthCalledWith( @@ -535,7 +535,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) const buildHistoryTurns = vi.mocked(contextBuilderModule.buildHistoryTurns) @@ -568,7 +568,7 @@ describe('CompactionService', () => { reserveTokens: 100, supportsVision: false, preserveInterleavedReasoning: false, - newUserContent: 'latest turn' + newUserContent: { text: 'latest turn', files: [] } }) expect(intent?.summaryBlocks.join('\n')).toContain('[Generation failed]\nReason: Unknown error') diff --git a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts index b4fcf1b395..d51c89bf2f 100644 --- a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts +++ b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts @@ -763,9 +763,18 @@ describe('buildContext', () => { makeAssistantRecord(4, 'recent reply') ] const store = createMockMessageStore(messages) - const result = buildContextWithMetadata('s1', 'next', 'System', 10000, 4096, store, false, { - summaryCursorOrderSeq: 3 - }) + const result = buildContextWithMetadata( + 's1', + { text: 'next', files: [] }, + 'System', + 10000, + 4096, + store, + false, + { + summaryCursorOrderSeq: 3 + } + ) expect(result.metadata.summaryCursor).toEqual({ summaryCursorOrderSeq: 3, @@ -783,7 +792,14 @@ describe('buildContext', () => { it('reports zero pre-cursor records when the cursor is at the start', () => { const messages = [makeUserRecord(1, 'a'), makeAssistantRecord(2, 'b')] const store = createMockMessageStore(messages) - const result = buildContextWithMetadata('s1', 'next', 'System', 10000, 4096, store) + const result = buildContextWithMetadata( + 's1', + { text: 'next', files: [] }, + 'System', + 10000, + 4096, + store + ) expect(result.metadata.summaryCursor).toEqual({ summaryCursorOrderSeq: 1, diff --git a/test/main/presenter/agentRuntimePresenter/process.test.ts b/test/main/presenter/agentRuntimePresenter/process.test.ts index eb34645674..44d132486b 100644 --- a/test/main/presenter/agentRuntimePresenter/process.test.ts +++ b/test/main/presenter/agentRuntimePresenter/process.test.ts @@ -50,7 +50,13 @@ vi.mock('@/presenter', () => ({ } })) -import { processStream } from '@/presenter/agentRuntimePresenter/process' +import { accumulate } from '@/presenter/agentRuntimePresenter/accumulator' +import { + INCOMPLETE_PROVIDER_STREAM_ERROR, + INCOMPLETE_TOOL_USE_ERROR, + processStream, + resolveProviderTerminalDecision +} from '@/presenter/agentRuntimePresenter/process' function expectDeepchatEvent(eventName: string, payload: Record): void { expect(publishDeepchatEventMock).toHaveBeenCalledWith(eventName, expect.objectContaining(payload)) @@ -64,6 +70,51 @@ const DEFAULT_INTERLEAVED_REASONING = { providerDbSourceUrl: 'https://example.com/provider-db.json' } as const +describe('provider terminal decisions', () => { + it('rejects a provider stream that ends without a stop event', () => { + expect(resolveProviderTerminalDecision(createState())).toEqual({ + type: 'error', + error: INCOMPLETE_PROVIDER_STREAM_ERROR, + source: 'provider', + stopReason: 'provider_error' + }) + }) + + it('preserves max_turn_requests as a bounded completion', () => { + const state = createState() + accumulate(state, { type: 'text', content: 'partial response' }) + accumulate(state, { type: 'stop', stop_reason: 'max_turn_requests' }) + + expect(resolveProviderTerminalDecision(state)).toEqual({ + type: 'complete', + stopReason: 'max_turn_requests' + }) + }) + + it('rejects tool_use without a completed tool call', () => { + const state = createState() + accumulate(state, { type: 'text', content: 'calling a tool' }) + accumulate(state, { type: 'stop', stop_reason: 'tool_use' }) + + expect(resolveProviderTerminalDecision(state)).toEqual({ + type: 'error', + error: INCOMPLETE_TOOL_USE_ERROR, + source: 'provider', + stopReason: 'provider_error' + }) + }) + + it('rejects a non-canonical provider stop reason instead of treating it as complete', () => { + const state = createState() + accumulate(state, { type: 'text', content: 'partial response' }) + state.stopReason = 'end_turn' as any + + expect(() => resolveProviderTerminalDecision(state)).toThrow( + 'Unsupported provider stop reason: end_turn' + ) + }) +}) + function createMockMessageStore() { return { addSearchResult: vi.fn(), diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts index a13385a9c9..c95da28624 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts @@ -80,13 +80,22 @@ describe('TapeViewAssembler', () => { supportsAudioInput: false } - const legacy = buildContextWithMetadata('s1', 'next user', 'System', 1000, 100, store, false, { - ...options, - historyRecords - }) + const legacy = buildContextWithMetadata( + 's1', + { text: 'next user', files: [] }, + 'System', + 1000, + 100, + store, + false, + { + ...options, + historyRecords + } + ) const assembled = buildTapeChatView({ sessionId: 's1', - newUserContent: 'next user', + newUserContent: { text: 'next user', files: [] }, systemPrompt: 'System', contextLength: 1000, reserveTokens: 100, @@ -162,7 +171,7 @@ describe('TapeViewAssembler', () => { const requested = buildTapeChatView({ sessionId: 's1', - newUserContent: 'next user', + newUserContent: { text: 'next user', files: [] }, systemPrompt: '', contextLength: 1000, reserveTokens: 100, @@ -174,7 +183,7 @@ describe('TapeViewAssembler', () => { const fallback = buildTapeChatView({ sessionId: 's1', - newUserContent: 'next user', + newUserContent: { text: 'next user', files: [] }, systemPrompt: '', contextLength: 1000, reserveTokens: 100, @@ -208,7 +217,7 @@ describe('TapeViewAssembler', () => { const assembled = buildTapeChatView({ sessionId: 's1', - newUserContent: 'next user', + newUserContent: { text: 'next user', files: [] }, systemPrompt: '', contextLength: 1000, reserveTokens: 100, diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts index fc5045d0dc..cd141fe316 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts @@ -71,7 +71,7 @@ describe('legacyTapeViewPolicy', () => { const store = createMockMessageStore(records) const input = { sessionId: 's1', - newUserContent: 'next user', + newUserContent: { text: 'next user', files: [] }, systemPrompt: 'System', contextLength: 1000, reserveTokens: 100, diff --git a/test/main/presenter/llmProviderPresenter/aiSdkStreamAdapter.test.ts b/test/main/presenter/llmProviderPresenter/aiSdkStreamAdapter.test.ts index f78a098403..3009556d07 100644 --- a/test/main/presenter/llmProviderPresenter/aiSdkStreamAdapter.test.ts +++ b/test/main/presenter/llmProviderPresenter/aiSdkStreamAdapter.test.ts @@ -176,7 +176,7 @@ describe('AI SDK stream adapter', () => { mimeType: 'image/png' } }) - expect(events[2]).toEqual({ type: 'stop', stop_reason: 'stop_sequence' }) + expect(events[2]).toEqual({ type: 'stop', stop_reason: 'complete' }) }) it('falls back to the original image data url when image caching fails', async () => { @@ -261,7 +261,78 @@ describe('AI SDK stream adapter', () => { total_tokens: 2 } }, - { type: 'stop', stop_reason: 'stop_sequence' } + { type: 'stop', stop_reason: 'complete' } + ]) + }) + + it('maps the length finish reason to max_tokens', async () => { + const events = await collectEvents( + [ + { + type: 'finish', + finishReason: 'length', + rawFinishReason: 'length', + totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } + } + ], + { supportsNativeTools: true } + ) + + expect(events.at(-1)).toEqual({ type: 'stop', stop_reason: 'max_tokens' }) + }) + + it('preserves max_tokens after parsing a legacy tool call', async () => { + const events = await collectEvents( + [ + { + type: 'text-delta', + id: 'text-1', + text: '{"function_call":{"name":"search","arguments":{"q":"deepchat"}}}' + }, + { + type: 'finish', + finishReason: 'length', + rawFinishReason: 'length', + totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } + } + ], + { supportsNativeTools: false } + ) + + expect(events.at(-1)).toEqual({ type: 'stop', stop_reason: 'max_tokens' }) + }) + + it.each([ + ['content-filter', 'Provider stopped the response because of content filtering.'], + ['error', 'Provider stopped the response because of an error.'], + ['other', 'Provider stopped the response for an unspecified reason: provider-specific'] + ] as const)('surfaces the %s finish reason as an error', async (finishReason, message) => { + const events = await collectEvents( + [ + { + type: 'finish', + finishReason, + rawFinishReason: finishReason === 'other' ? 'provider-specific' : finishReason, + totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } + } + ], + { supportsNativeTools: true } + ) + + expect(events.slice(-2)).toEqual([ + { type: 'error', error_message: message }, + { type: 'stop', stop_reason: 'error' } + ]) + }) + + it('surfaces an SDK abort part as a provider error', async () => { + const events = await collectEvents([{ type: 'abort', reason: 'upstream aborted' }], { + supportsNativeTools: true + }) + + expect(events).toEqual([ + { type: 'error', error_message: 'upstream aborted' }, + { type: 'stop', stop_reason: 'error' } ]) }) }) diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index f65f545573..cc539d8216 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -41,7 +41,7 @@ function createHarness() { } describe('SessionAgentAssignmentPolicy', () => { - it('resolves DeepChat creation precedence and normalizes persisted settings', async () => { + it('resolves DeepChat creation precedence and configured settings', async () => { const { policy } = createHarness() await expect( @@ -65,6 +65,25 @@ describe('SessionAgentAssignmentPolicy', () => { }) }) + it('owns the full-access default for omitted assignment modes', async () => { + const { policy } = createHarness() + + await expect( + policy.resolveCreateAssignment({ + agentId: 'reviewer', + preserveExplicitNullProjectDir: false + }) + ).resolves.toMatchObject({ permissionMode: 'full_access' }) + expect(policy.resolveAcpDraftAssignment('claude-acp')).toEqual({ + agentId: 'claude-acp', + permissionMode: 'full_access' + }) + expect(policy.resolveAcpDraftAssignment('claude-acp', 'default')).toEqual({ + agentId: 'claude-acp', + permissionMode: 'default' + }) + }) + it('preserves explicit null only for attached creation', async () => { const { policy } = createHarness() diff --git a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts index d28c1b88bd..24af64790c 100644 --- a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts +++ b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts @@ -598,7 +598,7 @@ function createMockLlmProviderPresenter() { type: 'usage', usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } })() }) }), @@ -1111,7 +1111,7 @@ describe('Integration: multi-turn context', () => { releaseFirstTurn = resolve }) yield { type: 'text', content: 'First response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1140,14 +1140,14 @@ describe('Integration: multi-turn context', () => { .fn() .mockImplementationOnce(async function* () { yield { type: 'text', content: 'First response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) .mockImplementationOnce(async function* () { await new Promise((resolve) => { releaseSecondTurn = resolve }) yield { type: 'text', content: 'Second response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1184,11 +1184,11 @@ describe('Integration: multi-turn context', () => { releaseFirstTurn = resolve }) yield { type: 'text', content: 'First response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) .mockImplementation(async function* () { yield { type: 'text', content: 'Queued response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1230,11 +1230,11 @@ describe('Integration: multi-turn context', () => { releaseFirstTurn = resolve }) yield { type: 'text', content: 'First response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) .mockImplementation(async function* () { yield { type: 'text', content: 'Second response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1299,11 +1299,11 @@ describe('Integration: multi-turn context', () => { releaseFirstTurn = resolve }) yield { type: 'text', content: firstResponse } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) .mockImplementation(async function* () { yield { type: 'text', content: 'Second response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1378,7 +1378,7 @@ describe('Integration: multi-turn context', () => { typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : 'unknown' }` } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1464,7 +1464,7 @@ describe('Integration: multi-turn context', () => { }) .mockImplementation(async function* () { yield { type: 'text', content: 'Recovered response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) @@ -1505,7 +1505,7 @@ describe('Integration: multi-turn context', () => { }) .mockImplementation(async function* () { yield { type: 'text', content: 'Recovered queued response' } - yield { type: 'stop', stop_reason: 'end_turn' } + yield { type: 'stop', stop_reason: 'complete' } }) } llmProvider.getProviderInstance.mockReturnValue(providerInstance) diff --git a/test/main/presenter/sessionApplication/turnCoordinator.test.ts b/test/main/presenter/sessionApplication/turnCoordinator.test.ts index 76df4ba752..54984cea16 100644 --- a/test/main/presenter/sessionApplication/turnCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/turnCoordinator.test.ts @@ -192,6 +192,43 @@ describe('SessionTurnCoordinator', () => { }) }) + it('canonicalizes structured live send and steer input at the application boundary', async () => { + const harness = createHarness({ hasMessages: true }) + const file = { name: 'brief.md', path: '/repo/brief.md' } + const inlineItem = { + type: 'file' as const, + offset: 3, + fileName: file.name, + filePath: file.path + } + + await harness.coordinator.sendMessage('s1', { + text: 'Send', + files: [file], + activeSkills: [' review ', 'review'], + inlineItems: [inlineItem] + }) + await harness.coordinator.steerActiveTurn('s1', { + text: 'Steer', + files: [file], + activeSkills: [' review ', 'review'], + inlineItems: [inlineItem] + }) + + const canonicalInput = { + files: [file], + activeSkills: ['review'], + inlineItems: [inlineItem] + } + expect(harness.send).toHaveBeenCalledWith( + expect.objectContaining({ content: { text: 'Send', ...canonicalInput } }) + ) + expect(harness.pending.steerActiveTurn).toHaveBeenCalledWith({ + text: 'Steer', + ...canonicalInput + }) + }) + it.each([ ['send', (coordinator: SessionTurnCoordinator) => coordinator.sendMessage('draft', 'Prompt')], [ @@ -351,6 +388,7 @@ describe('SessionTurnCoordinator', () => { it('starts the lifecycle initial turn without awaiting it and schedules title generation', () => { const harness = createHarness() + const content = { text: 'Initial', files: [], activeSkills: ['review'] } let resolveSend: (() => void) | undefined harness.send.mockReturnValue( new Promise((resolve) => { @@ -361,7 +399,7 @@ describe('SessionTurnCoordinator', () => { expect( harness.coordinator.startInitialTurn({ sessionId: 's1', - content: { text: 'Initial', files: [], activeSkills: [' review ', 'review'] }, + content, projectDir: '/repo', initialTitle: 'Initial', fallbackProviderId: 'openai', @@ -370,7 +408,7 @@ describe('SessionTurnCoordinator', () => { ).toBeUndefined() expect(harness.send).toHaveBeenCalledWith({ - content: { text: 'Initial', files: [], activeSkills: ['review'] }, + content, context: { projectDir: '/repo' }, queue: { source: 'send', projectDir: '/repo' } }) diff --git a/test/main/presenter/sqlitePresenter.test.ts b/test/main/presenter/sqlitePresenter.test.ts index fbb3a298ac..6daecbc98b 100644 --- a/test/main/presenter/sqlitePresenter.test.ts +++ b/test/main/presenter/sqlitePresenter.test.ts @@ -214,7 +214,7 @@ describeIfSqlite('SQLitePresenter legacy schema bootstrap', () => { const presenter = new SQLitePresenterCtor(dbPath) presenter.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) - presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli') + presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') await presenter.upsertAcpSession('conversation-1', 'kimi-cli', { sessionId: 'acp-session-1', status: 'active' @@ -548,7 +548,7 @@ describeIfSqlite('SQLitePresenter legacy schema bootstrap', () => { bootstrapDb.close() const presenter = new SQLitePresenterCtor(dbPath) - presenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + presenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') presenter.close() const checkDb = new DatabaseCtor(dbPath) diff --git a/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts b/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts index 624a0a51a4..871f2ffd3f 100644 --- a/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts +++ b/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts @@ -184,7 +184,8 @@ describe('DeepChatSessionsTable.updateSummaryStateIfMatches', () => { }) }) - it('normalizes invalid persisted permission modes on read', () => { + it('decodes persisted permission modes at the database boundary', () => { + let permissionMode: string | null = 'default' prepare.mockImplementation((sql: string) => { if (sql === 'SELECT * FROM deepchat_sessions WHERE id = ?') { return { @@ -192,7 +193,7 @@ describe('DeepChatSessionsTable.updateSummaryStateIfMatches', () => { id: 's1', provider_id: 'openai', model_id: 'gpt-4', - permission_mode: 'unexpected' + permission_mode: permissionMode }) } } @@ -200,7 +201,16 @@ describe('DeepChatSessionsTable.updateSummaryStateIfMatches', () => { throw new Error(`Unexpected SQL: ${sql}`) }) - expect(table.get('s1')?.permission_mode).toBe('full_access') + for (const [stored, expected] of [ + ['default', 'default'], + ['auto_approve', 'auto_approve'], + ['full_access', 'full_access'], + ['unexpected', 'default'], + [null, 'default'] + ] as const) { + permissionMode = stored + expect(table.get('s1')?.permission_mode).toBe(expected) + } }) it('updates the memory cursor with a monotonic MAX guard (C2, AC-2.1)', () => { diff --git a/test/main/presenter/usageStatsService.test.ts b/test/main/presenter/usageStatsService.test.ts index f724ebb085..b9192e07ba 100644 --- a/test/main/presenter/usageStatsService.test.ts +++ b/test/main/presenter/usageStatsService.test.ts @@ -436,7 +436,7 @@ describe('UsageStatsService', () => { 'listAssistantUsageCandidatesPage' ) - sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') sqlitePresenter.deepchatMessagesTable.insert({ id: 'message-1', sessionId: 'session-1', @@ -480,7 +480,7 @@ describe('UsageStatsService', () => { it('keeps concurrent backfill requests single-flight', async () => { const { service, sqlitePresenter, configPresenter } = createService() - sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') for (let index = 0; index < 50; index += 1) { sqlitePresenter.deepchatMessagesTable.insert({ id: `message-${index}`, @@ -549,7 +549,7 @@ describe('UsageStatsService', () => { const { service, sqlitePresenter } = createService() const messageStore = new DeepChatMessageStore(sqlitePresenter) - sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') sqlitePresenter.deepchatMessagesTable.insert({ id: 'message-1', sessionId: 'session-1', @@ -595,7 +595,7 @@ describe('UsageStatsService', () => { it('reads dashboard data from deepchat_usage_stats only', async () => { const { service, sqlitePresenter } = createService() - sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o', 'full_access') sqlitePresenter.deepchatMessagesTable.insert({ id: 'message-1', sessionId: 'session-1', diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index ec863c6f3b..7f53579398 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -37,9 +37,11 @@ import { sessionsDeactivateRoute, sessionsGetActiveRoute, sessionsListRoute, + sessionsQueuePendingInputRoute, sessionsRestoreRoute, sessionsSetPermissionModeRoute, sessionsUpdateGenerationSettingsRoute, + sessionsUpdateQueuedInputRoute, systemOpenSettingsRoute, windowConsumePendingSettingsProviderInstallRoute, windowRequeuePendingSettingsProviderInstallRoute @@ -1027,6 +1029,60 @@ describe('main kernel contracts', () => { }) }) + it('validates pending input payload objects before dispatch', () => { + expect( + sessionsQueuePendingInputRoute.input.parse({ + sessionId: 'session-1', + content: 'queued text' + }) + ).toEqual({ + sessionId: 'session-1', + content: 'queued text' + }) + + expect( + sessionsUpdateQueuedInputRoute.input.parse({ + sessionId: 'session-1', + itemId: 'pending-1', + content: { + text: 'queued object', + files: [], + inlineItems: [{ type: 'skill', offset: 0, skillName: 'review' }] + } + }) + ).toEqual({ + sessionId: 'session-1', + itemId: 'pending-1', + content: { + text: 'queued object', + files: [], + inlineItems: [{ type: 'skill', offset: 0, skillName: 'review' }] + } + }) + + expect( + sessionsQueuePendingInputRoute.input.safeParse({ + sessionId: 'session-1', + content: { files: [] } + }).success + ).toBe(false) + expect( + sessionsQueuePendingInputRoute.input.safeParse({ + sessionId: 'session-1', + content: { text: 'bad file', files: [{ name: 'missing path' }] } + }).success + ).toBe(false) + expect( + sessionsQueuePendingInputRoute.input.safeParse({ + sessionId: 'session-1', + content: { + text: 'bad inline item', + inlineItems: [{ type: 'skill', offset: -1, skillName: 'review' }] + } + }).success + ).toBe(false) + }) + it('validates manual compaction route contracts', () => { expect( sessionsCompactRoute.input.parse({ From 2452846bdc8cd17db49961cd13efa1966e9dc693 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 15 Jul 2026 00:48:17 +0800 Subject: [PATCH 2/3] fix(agent): preserve pending queue continuity --- .../send-message-canonical-boundary/spec.md | 12 +++++++----- .../agent/deepchat/pending/pendingInputStore.ts | 17 ++++++++++++----- .../deepchat/pending/pendingInputStore.test.ts | 15 +++++++++++---- .../agentRuntimePresenter/tapeService.test.ts | 2 +- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/architecture/send-message-canonical-boundary/spec.md b/docs/architecture/send-message-canonical-boundary/spec.md index 365f5126f6..a3f11b61e0 100644 --- a/docs/architecture/send-message-canonical-boundary/spec.md +++ b/docs/architecture/send-message-canonical-boundary/spec.md @@ -17,8 +17,8 @@ application layer, while runtime ports should operate on one canonical object sh Pending queue and update ports now use the same canonical object below the application boundary. The store serializes that object directly; persisted payloads are decoded with -`SendMessageInputSchema`, and invalid JSON or shape is reported as corrupt storage instead of being -reinterpreted as user text. +`SendMessageInputSchema`. Invalid JSON or shape is reported as corrupt storage and degrades to the +raw payload as user text so one damaged record cannot block the pending queue. ## Goal @@ -53,7 +53,8 @@ session create input 4. `AgentSessionSendInput.content` and the live steer facets use `SendMessageInput`, not `string | SendMessageInput`. 5. Pending queue and update facets below the application boundary also use `SendMessageInput`. -6. Pending storage accepts only canonical objects and rejects invalid persisted JSON or shape. +6. Pending storage accepts only canonical objects. Invalid persisted JSON or shape is logged and + degrades to raw user text so queue processing remains continuous. 7. DeepChat turn execution and ACP prompt resource resolution receive `SendMessageInput` directly. 8. Context construction does not normalize the current live user input. Persisted history decoding remains separate and unchanged. @@ -94,8 +95,9 @@ session create input internal runtime ports remain canonical. - Narrow pending queue and update operations after the companion persistence-decoder work proved the supported stored format and removed write-side coercion. -- Decode persisted pending payloads with `SendMessageInputSchema`; do not provide a raw-text fallback - because the table has only ever stored JSON objects. +- Decode persisted pending payloads with `SendMessageInputSchema`; on failure, report the integrity + error and preserve the raw payload as user text because conversation continuity takes precedence + over reconstructing optional structure. - GitHub issue synchronization is not part of this work. ## Open Questions diff --git a/src/main/agent/deepchat/pending/pendingInputStore.ts b/src/main/agent/deepchat/pending/pendingInputStore.ts index 1982c3d585..1a2b1acd87 100644 --- a/src/main/agent/deepchat/pending/pendingInputStore.ts +++ b/src/main/agent/deepchat/pending/pendingInputStore.ts @@ -5,7 +5,6 @@ import type { SendMessageInput } from '@shared/types/agent-interface' import { SendMessageInputSchema } from '@shared/contracts/common' -import { normalizeSendMessageInput } from '@/agent/shared/agentSessionNormalization' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' import type { DeepChatPendingInputRow } from '@/presenter/sqlitePresenter/tables/deepchatPendingInputs' @@ -382,15 +381,23 @@ export class DeepChatPendingInputStore { let parsed: unknown try { parsed = JSON.parse(row.payload_json) - } catch { - throw new Error(`Invalid pending input payload JSON: ${row.id}`) + } catch (error) { + console.error( + `[DeepChatPendingInputStore] Invalid pending input payload JSON: ${row.id}`, + error + ) + return { text: row.payload_json, files: [] } } const result = SendMessageInputSchema.safeParse(parsed) if (!result.success) { - throw new Error(`Invalid pending input payload shape: ${row.id}`) + console.error( + `[DeepChatPendingInputStore] Invalid pending input payload shape: ${row.id}`, + result.error + ) + return { text: row.payload_json, files: [] } } - return normalizeSendMessageInput(result.data) + return result.data } } diff --git a/test/main/agent/deepchat/pending/pendingInputStore.test.ts b/test/main/agent/deepchat/pending/pendingInputStore.test.ts index 28a37b1190..1deeb11458 100644 --- a/test/main/agent/deepchat/pending/pendingInputStore.test.ts +++ b/test/main/agent/deepchat/pending/pendingInputStore.test.ts @@ -158,13 +158,20 @@ describe('DeepChatPendingInputStore', () => { ['invalid JSON', 'not-json', 'JSON'], ['JSON string', JSON.stringify('legacy text'), 'shape'], ['invalid object', JSON.stringify({ files: [] }), 'shape'] - ])('rejects %s instead of converting it to user text', (_label, payloadJson, errorKind) => { + ])('degrades %s to raw text without blocking the queue', (_label, payloadJson, errorKind) => { const row = createQueueRow('corrupt-1', 'session-1', 1, 'pending') row.payload_json = payloadJson const { store } = createStore([row]) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - expect(() => store.getInput('corrupt-1')).toThrow( - `Invalid pending input payload ${errorKind}: corrupt-1` - ) + try { + expect(store.getInput('corrupt-1')?.payload).toEqual({ text: payloadJson, files: [] }) + expect(consoleError).toHaveBeenCalledWith( + `[DeepChatPendingInputStore] Invalid pending input payload ${errorKind}: corrupt-1`, + expect.anything() + ) + } finally { + consoleError.mockRestore() + } }) }) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 4eda35192c..6dc3e40646 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -3628,7 +3628,7 @@ describe('DeepChatTapeService', () => { messageTable.createTable() traceTable.createTable() sessionTable.createTable() - sessionTable.create('s1', 'openai', 'gpt-4o') + sessionTable.create('s1', 'openai', 'gpt-4o', 'full_access') sessionTable.updateMemoryCursorOrderSeq('s1', 3) appendObservationIsolationFacts(tapeTable) From 7d66ae8b0fad9301376f8a193b6f0f9bf1f36415 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 15 Jul 2026 10:08:22 +0800 Subject: [PATCH 3/3] fix(agent): address review feedback --- .../provider-round-stop-contract/spec.md | 3 + .../agent/acp/runtime/acpContentMapper.ts | 6 + .../agentRuntimePresenter/contextBuilder.ts | 6 +- .../agentRuntimePresenter/process.ts | 13 +-- .../acp/runtime/acpContentMapper.test.ts | 7 ++ .../contextBuilder.test.ts | 109 ++++++++++++------ .../agentRuntimePresenter/tapeService.test.ts | 4 +- test/main/presenter/usageStatsService.test.ts | 7 +- 8 files changed, 105 insertions(+), 50 deletions(-) diff --git a/docs/architecture/provider-round-stop-contract/spec.md b/docs/architecture/provider-round-stop-contract/spec.md index 2d8ecd86ba..580cccb1ad 100644 --- a/docs/architecture/provider-round-stop-contract/spec.md +++ b/docs/architecture/provider-round-stop-contract/spec.md @@ -57,6 +57,9 @@ ACP reasons are converted once at the ACP content boundary: | `refusal` | error event + `error` stop | | `cancelled` | error event + `error` stop | +An unsupported runtime stop reason produces an error event and `error` stop. The exhaustive switch +still fails compilation when the SDK adds a declared reason that has not been mapped. + ## Acceptance Criteria - `StopStreamEvent.stop_reason` uses one exported closed type. diff --git a/src/main/agent/acp/runtime/acpContentMapper.ts b/src/main/agent/acp/runtime/acpContentMapper.ts index 0404caacf9..d48b1c6f63 100644 --- a/src/main/agent/acp/runtime/acpContentMapper.ts +++ b/src/main/agent/acp/runtime/acpContentMapper.ts @@ -26,6 +26,12 @@ export function createAcpPromptTerminalEvents( case 'end_turn': return [createStreamEvent.stop('complete')] } + + const unsupportedReason: never = reason + return [ + createStreamEvent.error(`Unsupported ACP prompt stop reason: ${String(unsupportedReason)}`), + createStreamEvent.stop('error') + ] } export interface PlanEntry { diff --git a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts index 0a2b8776d9..a17d4b8386 100644 --- a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts +++ b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts @@ -1085,7 +1085,7 @@ function buildSummaryCursorMetadata( export function buildContext( sessionId: string, - newUserContent: string | SendMessageInput, + newUserContent: SendMessageInput, systemPrompt: string, contextLength: number, reserveTokens: number, @@ -1093,11 +1093,9 @@ export function buildContext( supportsVision: boolean = false, options: ContextBuildOptions = {} ): ChatMessage[] { - const input = - typeof newUserContent === 'string' ? { text: newUserContent, files: [] } : newUserContent return buildContextWithMetadata( sessionId, - input, + newUserContent, systemPrompt, contextLength, reserveTokens, diff --git a/src/main/presenter/agentRuntimePresenter/process.ts b/src/main/presenter/agentRuntimePresenter/process.ts index 5cc0862daf..c9326c3ab8 100644 --- a/src/main/presenter/agentRuntimePresenter/process.ts +++ b/src/main/presenter/agentRuntimePresenter/process.ts @@ -559,14 +559,6 @@ function settleLoopOutcome( } } - if (outcome.type === 'max_tool_calls') { - logger.info( - `[ProcessStream] max tool calls reached (${outcome.attemptedToolCount} > ${outcome.limit}), stopping` - ) - state.planTerminalReason = 'max_steps' - markUnexecutedToolCallsForLimit(state) - } - if (io.abortSignal.aborted) { stampRunOutcome(state, 'aborted', 'user_stop') finalizeUserCanceledErrorIfNeeded(state, io) @@ -578,6 +570,11 @@ function settleLoopOutcome( } } if (outcome.type === 'max_tool_calls') { + logger.info( + `[ProcessStream] max tool calls reached (${outcome.attemptedToolCount} > ${outcome.limit}), stopping` + ) + state.planTerminalReason = 'max_steps' + markUnexecutedToolCallsForLimit(state) stampRunOutcome(state, 'completed', 'max_tool_calls') outputSink.complete({ runId: run.runId, diff --git a/test/main/agent/acp/runtime/acpContentMapper.test.ts b/test/main/agent/acp/runtime/acpContentMapper.test.ts index e8280b8a22..3f3eb6a6eb 100644 --- a/test/main/agent/acp/runtime/acpContentMapper.test.ts +++ b/test/main/agent/acp/runtime/acpContentMapper.test.ts @@ -35,6 +35,13 @@ describe('ACP prompt stop reason mapping', () => { ] as const)('maps %s to its complete terminal event sequence', (input, expected) => { expect(createAcpPromptTerminalEvents(input)).toEqual(expected) }) + + it('fails an unsupported runtime stop reason without completing the turn', () => { + expect(createAcpPromptTerminalEvents('unexpected' as never)).toEqual([ + { type: 'error', error_message: 'Unsupported ACP prompt stop reason: unexpected' }, + { type: 'stop', stop_reason: 'error' } + ]) + }) }) describe('AcpContentMapper tool call handling', () => { diff --git a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts index d51c89bf2f..b98bc64ae6 100644 --- a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts +++ b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts @@ -341,7 +341,14 @@ describe('truncateContext', () => { describe('buildContext', () => { it('returns [system, user] when no history', () => { const store = createMockMessageStore([]) - const result = buildContext('s1', 'Hello', 'You are helpful', 10000, 4096, store) + const result = buildContext( + 's1', + { text: 'Hello', files: [] }, + 'You are helpful', + 10000, + 4096, + store + ) expect(result).toEqual([ { role: 'system', content: 'You are helpful' }, @@ -351,14 +358,14 @@ describe('buildContext', () => { it('omits system message when system prompt is empty', () => { const store = createMockMessageStore([]) - const result = buildContext('s1', 'Hello', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'Hello', files: [] }, '', 10000, 4096, store) expect(result).toEqual([{ role: 'user', content: 'Hello' }]) }) it('omits blank text-only user messages from prompts', () => { const store = createMockMessageStore([makeUserRecord(1, ' ')]) - const result = buildContext('s1', ' ', '', 10000, 4096, store) + const result = buildContext('s1', { text: ' ', files: [] }, '', 10000, 4096, store) expect(result).toEqual([]) }) @@ -400,7 +407,14 @@ describe('buildContext', () => { it('includes single prior exchange', () => { const messages = [makeUserRecord(1, 'First message'), makeAssistantRecord(2, 'First reply')] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'Second message', 'System', 10000, 4096, store) + const result = buildContext( + 's1', + { text: 'Second message', files: [] }, + 'System', + 10000, + 4096, + store + ) expect(result).toEqual([ { role: 'system', content: 'System' }, @@ -418,7 +432,7 @@ describe('buildContext', () => { makeAssistantRecord(4, 'reply2') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'msg3', 'System', 10000, 4096, store) + const result = buildContext('s1', { text: 'msg3', files: [] }, 'System', 10000, 4096, store) expect(result).toHaveLength(6) // system + 4 history + new user expect(result[0]).toEqual({ role: 'system', content: 'System' }) @@ -437,7 +451,7 @@ describe('buildContext', () => { makeAssistantRecord(4, 'good reply') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'msg3', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'msg3', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'msg1' }, @@ -451,7 +465,7 @@ describe('buildContext', () => { it('filters out pending messages', () => { const messages = [makeUserRecord(1, 'msg1'), makeAssistantRecord(2, 'pending reply', 'pending')] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'msg2', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'msg2', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'msg1' }, @@ -462,7 +476,7 @@ describe('buildContext', () => { it('filters out errored user messages', () => { const messages = [makeUserRecord(1, 'failed submit', 'error'), makeUserRecord(2, 'msg2')] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'msg3', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'msg3', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'msg2' }, @@ -476,7 +490,7 @@ describe('buildContext', () => { makeAssistantErrorRecord(2, 'common.error.userCanceledGeneration') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'continue', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'continue', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'stop this' }, @@ -494,7 +508,7 @@ describe('buildContext', () => { makeAssistantErrorRecord(2, 'timeout', 'Partial answer') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'continue', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'continue', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'do work' }, @@ -512,7 +526,7 @@ describe('buildContext', () => { makeAssistantErrorWithToolRecord(2, 'Checking...', 'tool result', 'terminal failure') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'continue', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'continue', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'use a tool' }, @@ -539,7 +553,7 @@ describe('buildContext', () => { makeAssistantWithReasoningRecord(2, 'The answer is 42', 'Let me think...') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'Follow up', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'Follow up', files: [] }, '', 10000, 4096, store) expect(result[1]).toEqual({ role: 'assistant', @@ -553,9 +567,18 @@ describe('buildContext', () => { makeAssistantWithReasoningRecord(2, 'The answer is 42', 'Let me think...') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'Follow up', '', 10000, 4096, store, false, { - preserveInterleavedReasoning: true - }) + const result = buildContext( + 's1', + { text: 'Follow up', files: [] }, + '', + 10000, + 4096, + store, + false, + { + preserveInterleavedReasoning: true + } + ) expect(result[1]).toEqual({ role: 'assistant', @@ -567,10 +590,19 @@ describe('buildContext', () => { it('does not add empty reasoning_content for non-tool assistant history', () => { const messages = [makeUserRecord(1, 'Think about this'), makeAssistantRecord(2, 'Answer only')] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'Follow up', '', 10000, 4096, store, false, { - preserveInterleavedReasoning: true, - preserveEmptyInterleavedReasoning: true - }) + const result = buildContext( + 's1', + { text: 'Follow up', files: [] }, + '', + 10000, + 4096, + store, + false, + { + preserveInterleavedReasoning: true, + preserveEmptyInterleavedReasoning: true + } + ) expect(result[1]).toEqual({ role: 'assistant', @@ -584,7 +616,7 @@ describe('buildContext', () => { makeAssistantWithReasoningAndToolRecord(2, 'Tool finished', 'Let me think...', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store, false, { + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, false, { preserveInterleavedReasoning: true }) @@ -613,7 +645,7 @@ describe('buildContext', () => { makeAssistantWithToolRecord(2, '', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store, false, { + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, false, { preserveInterleavedReasoning: true, preserveEmptyInterleavedReasoning: true }) @@ -643,7 +675,7 @@ describe('buildContext', () => { makeAssistantWithToolRecord(2, '', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store, false, { + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, false, { preserveInterleavedReasoning: true }) @@ -666,7 +698,7 @@ describe('buildContext', () => { makeAssistantWithReasoningAndToolRecord(2, 'Tool finished', 'Let me think...', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store, false, { + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, false, { preserveInterleavedReasoning: false }) @@ -703,7 +735,7 @@ describe('buildContext', () => { // available = 300 - 4 - 3 - 100 = 193 tokens // total history = 100+100+10+10 = 220 tokens > 193 // Drop first (100) → 120 > 193? No, 120 < 193 → fits - const result = buildContext('s1', 'New message', 'Sys', 300, 100, store) + const result = buildContext('s1', { text: 'New message', files: [] }, 'Sys', 300, 100, store) // Should include: system + (msg3, reply3, msg4, reply4 — minus oldest) + new user // First pair (100+100=200) dropped, remaining (10+10=20) fits @@ -721,7 +753,7 @@ describe('buildContext', () => { const store = createMockMessageStore(messages) // contextLength=100, maxTokens=50 → available very small - const result = buildContext('s1', 'Hi', 'Sys', 100, 50, store) + const result = buildContext('s1', { text: 'Hi', files: [] }, 'Sys', 100, 50, store) expect(result).toEqual([ { role: 'system', content: 'Sys' }, @@ -731,7 +763,7 @@ describe('buildContext', () => { it('calls getMessages with correct sessionId', () => { const store = createMockMessageStore([]) - buildContext('my-session', 'Hello', '', 10000, 4096, store) + buildContext('my-session', { text: 'Hello', files: [] }, '', 10000, 4096, store) expect(store.getMessages).toHaveBeenCalledWith('my-session') }) @@ -743,9 +775,18 @@ describe('buildContext', () => { makeAssistantRecord(4, 'recent reply') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', 'System', 10000, 4096, store, false, { - summaryCursorOrderSeq: 3 - }) + const result = buildContext( + 's1', + { text: 'next', files: [] }, + 'System', + 10000, + 4096, + store, + false, + { + summaryCursorOrderSeq: 3 + } + ) expect(result).toEqual([ { role: 'system', content: 'System' }, @@ -818,7 +859,7 @@ describe('buildContext', () => { const store = createMockMessageStore(messages) const result = buildContext( 's1', - 'new user already persisted', + { text: 'new user already persisted', files: [] }, 'System', 10000, 4096, @@ -844,7 +885,7 @@ describe('buildContext', () => { makeAssistantWithToolRecord(3, 'Tool finished', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'check this' }, @@ -871,7 +912,7 @@ describe('buildContext', () => { makeAssistantWithToolProviderOptionsRecord(2, 'Tool finished', 'All good') ] const store = createMockMessageStore(messages) - const result = buildContext('s1', 'next', '', 10000, 4096, store) + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store) expect(result).toEqual([ { role: 'user', content: 'check this' }, @@ -969,7 +1010,7 @@ describe('buildContext', () => { ]) ]) - const result = buildContext('s1', 'next', '', 10000, 4096, store, true) + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, true) const userHistory = result[0] expect(Array.isArray(userHistory.content)).toBe(false) expect(userHistory.content).toEqual(expect.stringContaining('[Attached Image 1]')) @@ -1041,7 +1082,7 @@ describe('buildContext', () => { } ]) ]) - const result = buildContext('s1', 'next', '', 10000, 4096, store, false, { + const result = buildContext('s1', { text: 'next', files: [] }, '', 10000, 4096, store, false, { supportsAudioInput: true }) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 6dc3e40646..ed999c6af6 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -2170,7 +2170,7 @@ describe('DeepChatTapeService', () => { const legacyContext = buildContext( 's1', - 'next', + { text: 'next', files: [] }, 'System', 10000, 4096, @@ -2184,7 +2184,7 @@ describe('DeepChatTapeService', () => { } const tapeContext = buildContext( 's1', - 'next', + { text: 'next', files: [] }, 'System', 10000, 4096, diff --git a/test/main/presenter/usageStatsService.test.ts b/test/main/presenter/usageStatsService.test.ts index b9192e07ba..c24d4471ce 100644 --- a/test/main/presenter/usageStatsService.test.ts +++ b/test/main/presenter/usageStatsService.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { DeepChatMessageStore } from '@/presenter/agentRuntimePresenter/messageStore' import { DASHBOARD_STATS_BACKFILL_KEY, type UsageStatsRecordInput } from '@/presenter/usageStats' import { UsageStatsService } from '@/presenter/usageStatsService' +import type { PermissionMode } from '@shared/types/agent-interface' vi.mock('@/eventbus', () => ({ eventBus: { sendToMain: vi.fn(), on: vi.fn() } @@ -68,6 +69,7 @@ type SessionRow = { id: string provider_id: string model_id: string + permission_mode: PermissionMode } type MessageRow = { @@ -161,11 +163,12 @@ function createMockSqlitePresenter() { const usageStats = new Map() const deepchatSessionsTable = { - create(sessionId: string, providerId: string, modelId: string) { + create(sessionId: string, providerId: string, modelId: string, permissionMode: PermissionMode) { sessions.set(sessionId, { id: sessionId, provider_id: providerId, - model_id: modelId + model_id: modelId, + permission_mode: permissionMode }) }, get(sessionId: string) {