diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md index 66892ecea..b6c9f3840 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 000000000..75b166544 --- /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 000000000..580cccb1a --- /dev/null +++ b/docs/architecture/provider-round-stop-contract/spec.md @@ -0,0 +1,101 @@ +# 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 | + +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. +- 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 000000000..a3f11b61e --- /dev/null +++ b/docs/architecture/send-message-canonical-boundary/spec.md @@ -0,0 +1,105 @@ +# 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`. 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 + +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. 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. +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`; 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 + +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 0782b62c5..000000000 --- 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 a45cd250c..3bbaeb5d2 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 ca64f8035..97ca680bf 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 fba27ef48..0aae7ac32 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 70373fc3c..d48b1c6f6 100644 --- a/src/main/agent/acp/runtime/acpContentMapper.ts +++ b/src/main/agent/acp/runtime/acpContentMapper.ts @@ -5,21 +5,33 @@ 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')] } + + 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/agent/acp/runtime/index.ts b/src/main/agent/acp/runtime/index.ts index 31116f7dd..42d2e39ef 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 433f33244..05c022f0c 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 94bf19923..49f41f780 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 d5d0d9e13..1a2b1acd8 100644 --- a/src/main/agent/deepchat/pending/pendingInputStore.ts +++ b/src/main/agent/deepchat/pending/pendingInputStore.ts @@ -4,35 +4,12 @@ import type { PendingSessionInputState, SendMessageInput } from '@shared/types/agent-interface' +import { SendMessageInputSchema } from '@shared/contracts/common' 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 +53,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 +70,7 @@ export class DeepChatPendingInputStore { sessionId, mode: 'queue', state, - payloadJson: JSON.stringify(normalized), + payloadJson: JSON.stringify(input), queueOrder: nextQueueOrder, claimedAt }) @@ -105,15 +81,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 +99,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 +108,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 +134,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 +368,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 +377,27 @@ export class DeepChatPendingInputStore { } } - private parsePayload(raw: string): SendMessageInput { + private decodePayload(row: DeepChatPendingInputRow): SendMessageInput { + let parsed: unknown try { - return normalizeInput(JSON.parse(raw) as SendMessageInput) - } catch { - return normalizeInput(raw) + parsed = JSON.parse(row.payload_json) + } 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) { + console.error( + `[DeepChatPendingInputStore] Invalid pending input payload shape: ${row.id}`, + result.error + ) + return { text: row.payload_json, files: [] } } + + return result.data } } diff --git a/src/main/agent/manager/deepChatAgentBackend.ts b/src/main/agent/manager/deepChatAgentBackend.ts index 504eb9dfd..93ae995a0 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 7602745d3..fc69fdfd6 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 42935acf4..96cdb6f83 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 b2a9cf90f..b4772eae2 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 44a2c71d5..62c6722fe 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 f9daff199..8ae8d86be 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 31731f6de..b843c44b5 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 6e48f7d27..a17d4b838 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 @@ -1086,7 +1085,7 @@ function buildSummaryCursorMetadata( export function buildContext( sessionId: string, - newUserContent: string | SendMessageInput, + newUserContent: SendMessageInput, systemPrompt: string, contextLength: number, reserveTokens: number, @@ -1108,7 +1107,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 88ff5bb90..484b8d462 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 0b4301ed2..9abd66bcc 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 03c22f653..5ec427429 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 b0455d0b0..c9326c3ab 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[] { @@ -532,15 +559,6 @@ function settleLoopOutcome( } } - if (outcome.type === 'max_tool_calls') { - logger.info( - `[ProcessStream] max tool calls reached (${outcome.attemptedToolCount} > ${outcome.limit}), stopping` - ) - state.stopReason = 'max_tool_calls' - state.planTerminalReason = 'max_steps' - markUnexecutedToolCallsForLimit(state) - } - if (io.abortSignal.aborted) { stampRunOutcome(state, 'aborted', 'user_stop') finalizeUserCanceledErrorIfNeeded(state, io) @@ -551,6 +569,26 @@ function settleLoopOutcome( usage: buildUsageSnapshot(state) } } + 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, + 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 +834,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 3e4fc0455..4f7d0238b 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 038d73d27..f69acf2a0 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 19a1fe87e..07342ce67 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 b3a63a28e..8265d9e66 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 328484b6d..a821cfe76 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 06a389521..433e87d39 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 45bf1599a..7926937da 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 27ccbecd2..cf2613f70 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 13f97ed88..262c379b3 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 d555fdc5c..673802c19 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 456a09e3f..b3e4fb7d2 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 6b44f2df8..cb07b2de1 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 354850d42..15b363141 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 2d89351ef..3f3eb6a6e 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,32 @@ 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) + }) + + 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' } + ]) }) }) diff --git a/test/main/agent/deepchat/pending/pendingInputStore.test.ts b/test/main/agent/deepchat/pending/pendingInputStore.test.ts index 5c5c4e874..1deeb1145 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,49 @@ 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'] + ])('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(() => {}) + + 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/agent/manager/deepChatAgentBackend.test.ts b/test/main/agent/manager/deepChatAgentBackend.test.ts index 2b736430d..7c0fd8413 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 73f3e9547..eb80c7fc5 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 93f14519a..7561a4576 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 906c99515..b586958e6 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 a751de65b..e55f722a0 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 12378110b..e78cf09c2 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 cad9efa2e..21e9eb7c9 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 b4fcf1b39..b98bc64ae 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' }, @@ -763,9 +804,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 +833,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, @@ -802,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, @@ -828,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' }, @@ -855,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' }, @@ -953,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]')) @@ -1025,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/process.test.ts b/test/main/presenter/agentRuntimePresenter/process.test.ts index eb3464567..44d132486 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/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 4eda35192..ed999c6af 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, @@ -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) diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts index a13385a9c..c95da2862 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 fc5045d0d..cd141fe31 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 f78a09840..3009556d0 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 f65f54557..cc539d821 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 d28c1b88b..24af64790 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 76df4ba75..54984cea1 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 fbb3a298a..6daecbc98 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 624a0a51a..871f2ffd3 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 f724ebb08..c24d4471c 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) { @@ -436,7 +439,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 +483,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 +552,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 +598,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 ec863c6f3..7f5357939 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({