diff --git a/docs/architecture/deepchat-tape-policy-provenance/plan.md b/docs/architecture/deepchat-tape-policy-provenance/plan.md new file mode 100644 index 000000000..865c05c64 --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-provenance/plan.md @@ -0,0 +1,52 @@ +# DeepChat Tape Policy Provenance - Plan + +## Architecture Decision + +Use the existing `TapeViewAssemblerResult.policyId` and `policyVersion` as the source of truth for +initial chat and resume manifests. Keep request-level tool-loop and context-pressure recovery +manifests as shadow policies because they are post-assembly request transformations. + +## Flow + +```text +TapeViewAssembler.buildTapeChatView() + -> result.policyId = legacy_context_v1 + -> runStreamForMessage(viewContext.policy = result.policyId) + -> ViewManifest.policy = legacy_context_v1 + -> ViewManifest.policyVersion = 1 + +Tool loop / context pressure recovery + -> request-level ViewManifest + -> shadow policy label + -> policyVersion = null +``` + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/shared/types/tape-view-manifest.ts` | Add `legacy_context_v1` and `policyVersion`. | +| `src/main/presenter/agentRuntimePresenter/tapeViewManifest.ts` | Persist policy version in manifest. | +| `src/main/presenter/agentRuntimePresenter/index.ts` | Pass assembler policy id/version into view context. | +| `src/main/presenter/agentRuntimePresenter/tapeService.ts` | Store policy version in event metadata. | +| `src/renderer/src/components/trace/TraceDialog.vue` | Show policy version when present. | +| Tests | Update manifest, service, and trace expectations. | + +## Compatibility + +- Existing shadow policy strings remain accepted by shared types and UI. +- Old manifests without `policyVersion` continue to render; the field is treated as absent/null. +- Replay slice export keeps its current lookup behavior. + +## Verification + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeService.test.ts +pnpm vitest run test/renderer/components/trace/TraceDialog.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck:node +pnpm run typecheck:web +``` diff --git a/docs/architecture/deepchat-tape-policy-provenance/spec.md b/docs/architecture/deepchat-tape-policy-provenance/spec.md new file mode 100644 index 000000000..9b514f33b --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-provenance/spec.md @@ -0,0 +1,56 @@ +# DeepChat Tape Policy Provenance - Spec + +Status: implemented SDD. This goal records the active Tape view policy in every ViewManifest. + +## Problem + +`TapeViewAssembler` returns `policyId` and `policyVersion`, but `ViewManifest.policy` still stores +the older shadow labels such as `legacy_context_shadow` and `resume_shadow`. This weakens the Tape +audit trail because the persisted manifest does not identify the actual `TapeViewPolicy` that +selected the initial chat or resume context. + +## Goals + +1. Store the active `TapeViewPolicy` id in `ViewManifest.policy` for initial chat and resume + requests. +2. Store the active policy version in `ViewManifest.policyVersion`. +3. Preserve existing `tool_loop_shadow`, `context_pressure_recovery_shadow`, and legacy shadow + policy labels for compatibility. +4. Show policy version in TraceDialog when present. +5. Keep replay export and manifest lookup compatible with old manifest records. + +## Non-Goals + +- Changing the default policy away from `legacy_context_v1`. +- Adding user-facing policy selection. +- Changing token-budget selection. +- Rewriting tool-loop or context-pressure recovery selection. + +## Acceptance Criteria + +1. Normal chat manifests use `policy = "legacy_context_v1"` and `policyVersion = 1`. +2. Resume manifests use `policy = "legacy_context_v1"` and `policyVersion = 1`. +3. Tool-loop manifests keep `policy = "tool_loop_shadow"` and `policyVersion = null`. +4. Context-pressure recovery manifests keep `policy = "context_pressure_recovery_shadow"` and + `policyVersion = null`. +5. Existing shadow policy values remain valid manifest values. +6. TraceDialog displays policy version when the manifest includes one. +7. Manifest, runtime, trace UI, replay, lint, typecheck, and targeted tests pass. + +## Contract + +```ts +export type DeepChatTapeViewPolicy = + | 'legacy_context_v1' + | 'legacy_context_shadow' + | 'resume_shadow' + | 'tool_loop_shadow' + | 'context_pressure_recovery_shadow' + +export interface DeepChatTapeViewManifest { + policy: DeepChatTapeViewPolicy + policyVersion: number | null +} +``` + +`legacy_context_shadow` and `resume_shadow` are accepted for older persisted manifests only. diff --git a/docs/architecture/deepchat-tape-policy-provenance/tasks.md b/docs/architecture/deepchat-tape-policy-provenance/tasks.md new file mode 100644 index 000000000..d44286542 --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-provenance/tasks.md @@ -0,0 +1,11 @@ +# DeepChat Tape Policy Provenance - Tasks + +## Implementation Tasks + +- [x] T1: Add `legacy_context_v1` and `policyVersion` to the ViewManifest contract. +- [x] T2: Pass assembler policy id/version into chat and resume ViewManifest creation. +- [x] T3: Keep tool-loop and context-pressure recovery manifests on shadow policy labels with null + policy version. +- [x] T4: Show policy version in TraceDialog when available. +- [x] T5: Update baseline docs and tests. +- [x] T6: Run focused tests, format, i18n, lint, and typecheck. diff --git a/docs/architecture/deepchat-tape-policy-selector/plan.md b/docs/architecture/deepchat-tape-policy-selector/plan.md new file mode 100644 index 000000000..0097805c8 --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-selector/plan.md @@ -0,0 +1,50 @@ +# DeepChat Tape Policy Selector - Plan + +## Architecture Decision + +Keep selector logic inside `src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts`. This avoids +adding a service and keeps the policy boundary local to the assembler. + +## Flow + +```text +TapeViewAssembler.buildTapeChatView() + -> resolveTapeViewPolicy() + -> policy.buildChat() + -> return messages + policy id/version + selection reason + +TapeViewAssembler.buildTapeResumeView() + -> resolveTapeViewPolicy() + -> policy.buildResume() + -> return messages + policy id/version + selection reason +``` + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts` | Add registry, lookup, list, and resolver helpers. | +| `src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts` | Resolve default policy through selector. | +| `test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts` | Cover registry and selector behavior. | +| `test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts` | Assert selection reason and injected policy behavior. | +| `docs/architecture/deepchat_tape_spec_v1.md` | Record the selector boundary. | + +## Compatibility + +- The default resolved policy remains `legacy_context_v1`. +- Existing injected policy tests continue to work. +- ViewManifest policy provenance remains `legacy_context_v1@1` for chat and resume. + +## Verification + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck:node +pnpm run typecheck:web +pnpm vitest run --reporter=dot +``` diff --git a/docs/architecture/deepchat-tape-policy-selector/spec.md b/docs/architecture/deepchat-tape-policy-selector/spec.md new file mode 100644 index 000000000..132cec19b --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-selector/spec.md @@ -0,0 +1,54 @@ +# DeepChat Tape Policy Selector - Spec + +Status: implemented SDD. This goal adds a policy registry and default selector for Tape view +assembly. + +## Problem + +`TapeViewAssembler` can accept an injected policy, but production assembly still selects the legacy +policy inline. The architecture needs a small selector boundary so future policy expansion can add +new policies without changing chat or resume assembly call sites. + +## Goals + +1. Add a `TapeViewPolicy` registry in the existing policy module. +2. Resolve the active policy through a selector for chat and resume assembly. +3. Keep `legacy_context_v1` as the default policy for all sessions. +4. Preserve current provider-bound message output. +5. Return policy selection reason in assembler metadata for audit/debugging. + +## Non-Goals + +- Introducing a new context-selection algorithm. +- Adding user-facing policy settings. +- Changing compaction, preflight, or context-pressure recovery. +- Adding a separate policy service or persistence table. + +## Acceptance Criteria + +1. `TapeViewAssembler` uses `resolveTapeViewPolicy()` for default policy selection. +2. `resolveTapeViewPolicy()` returns `legacy_context_v1` with reason `default` when no policy is + requested. +3. Unknown requested policy ids fall back to `legacy_context_v1` with reason `fallback_default`. +4. Injected test policies remain supported and report reason `injected`. +5. Assembler output remains provider-message equivalent with the previous implementation. +6. Policy registry tests cover list, lookup, default selection, and fallback selection. +7. Focused Tape tests, format, i18n, lint, typecheck, and full Vitest pass. + +## Contract + +```ts +export type TapeViewPolicySelectionReason = 'default' | 'requested' | 'fallback_default' | 'injected' + +export interface TapeViewPolicySelection { + policy: TapeViewPolicy + requestedPolicyId: string | null + reason: TapeViewPolicySelectionReason +} + +export function resolveTapeViewPolicy(input?: { + requestedPolicyId?: string | null +}): TapeViewPolicySelection +``` + +The first registry contains only `legacy_context_v1`. diff --git a/docs/architecture/deepchat-tape-policy-selector/tasks.md b/docs/architecture/deepchat-tape-policy-selector/tasks.md new file mode 100644 index 000000000..88e49aabc --- /dev/null +++ b/docs/architecture/deepchat-tape-policy-selector/tasks.md @@ -0,0 +1,10 @@ +# DeepChat Tape Policy Selector - Tasks + +## Implementation Tasks + +- [x] T1: Add registry, lookup, list, and resolver helpers to `tapeViewPolicy.ts`. +- [x] T2: Route `TapeViewAssembler` default selection through the resolver. +- [x] T3: Add selection reason to `TapeViewAssemblerResult`. +- [x] T4: Update policy and assembler tests. +- [x] T5: Update baseline Tape architecture docs. +- [x] T6: Run focused tests, format, i18n, lint, typecheck, and full tests. diff --git a/docs/architecture/deepchat-tape-replay-contract/plan.md b/docs/architecture/deepchat-tape-replay-contract/plan.md new file mode 100644 index 000000000..7fd6a9a7d --- /dev/null +++ b/docs/architecture/deepchat-tape-replay-contract/plan.md @@ -0,0 +1,58 @@ +# DeepChat Tape Replay Contract - Plan + +## Architecture Decision + +Add a replay-slice export on top of `DeepChatTapeService`. The service already owns manifest lookup +and has access to the SQLite presenter, so it remains the single Tape boundary. + +## Flow + +```text +renderer SessionClient.exportMessageTapeReplaySlice(messageId, options) + -> sessions.exportMessageTapeReplaySlice route + -> AgentSessionPresenter.exportMessageTapeReplaySlice(messageId, options) + -> AgentRuntimePresenter.exportMessageTapeReplaySlice(sessionId, messageId, options) + -> DeepChatTapeService.exportReplaySlice(sessionId, messageId, options) + -> select manifest by requestSeq or latest + -> find matching message trace by requestSeq + -> collect manifest/included/excluded/anchor tape entries + -> return deterministic replay slice +``` + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/shared/types/tape-replay.ts` | Add replay slice, trace snapshot, entry snapshot, and options types. | +| `src/main/presenter/agentRuntimePresenter/tapeService.ts` | Add `exportReplaySlice()`. | +| `src/main/presenter/agentRuntimePresenter/index.ts` | Expose agent-level replay export method. | +| `src/main/presenter/agentSessionPresenter/index.ts` | Resolve `messageId -> sessionId -> agent`. | +| `src/shared/contracts/routes/sessions.routes.ts` | Add typed replay export route. | +| `src/main/routes/index.ts` | Wire the route. | +| `src/renderer/api/SessionClient.ts` | Add replay export client method. | +| `test/main/presenter/agentRuntimePresenter/tapeService.test.ts` | Cover replay export behavior. | + +## Hashing + +- `entry.payloadHash`: hash of raw `payload_json`. +- `entry.metaHash`: hash of raw `meta_json`. +- `trace.headersHash`: hash of raw `headers_json`. +- `trace.bodyHash`: hash of raw `body_json`. +- `sliceHash`: deterministic hash of the returned slice with `sliceHash` empty. + +## Compatibility + +- Missing tape table returns `null`. +- Missing manifest returns `null`. +- Missing matching trace returns a manifest-only slice. +- Old traces keep working through existing trace APIs. + +## Verification + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeService.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +``` diff --git a/docs/architecture/deepchat-tape-replay-contract/spec.md b/docs/architecture/deepchat-tape-replay-contract/spec.md new file mode 100644 index 000000000..74c824756 --- /dev/null +++ b/docs/architecture/deepchat-tape-replay-contract/spec.md @@ -0,0 +1,86 @@ +# DeepChat Tape Replay Contract - Spec + +Status: implemented SDD. This goal extends the completed ViewManifest shadow-mode increment with a +stable replay/export contract. + +## Problem + +ViewManifest records which context facts participated in each provider request. The next Tape +architecture layer needs a typed export shape that joins a manifest, matching trace metadata, and +referenced tape entries into one deterministic slice. Without this slice, Inspector debugging and +future eval/replay tools still need to reimplement lookup rules. + +## Goals + +1. Export a `DeepChatTapeReplaySlice` for a message request sequence. +2. Reuse `DeepChatTapeService` and existing `deepchat_tape_entries` / `deepchat_message_traces` + storage. +3. Keep metadata-only export as the default. +4. Allow explicit inclusion of existing tape payloads and trace payloads for developer replay. +5. Produce stable hashes for slice, tape entry payloads, tape entry metadata, and trace payloads. +6. Return `null` when no manifest exists for the requested message or request sequence. + +## Non-Goals + +- Running live LLM replay. +- Replacing `buildContext()` or request preflight behavior. +- Adding a dedicated replay table. +- Adding cross-session memory retrieval. + +## User Stories + +- As a runtime debugger, I can export one request's manifest, trace metadata, and referenced tape + entries with one typed call. +- As an eval author, I can identify the exact manifest hash and request hash for a historical + request. +- As a privacy-conscious maintainer, I can inspect replay structure without duplicating raw prompt + or message content by default. + +## Acceptance Criteria + +1. `DeepChatTapeService` can export the latest replay slice for a message. +2. `DeepChatTapeService` can export a replay slice for an explicit `requestSeq`. +3. The slice includes the manifest record, matching trace metadata when present, referenced tape + entry snapshots, anchor refs, and stable hashes. +4. The default export omits tape `payload` / `meta` and trace `headersJson` / `bodyJson`. +5. Explicit options can include tape payloads and trace payloads from their existing storage paths. +6. A typed route and renderer client method expose the export by `messageId`. +7. Tests cover default privacy behavior, explicit payload inclusion, missing manifests, and + request-sequence selection. + +## Contract + +```ts +export interface DeepChatTapeReplaySlice { + schemaVersion: 1 + sliceId: string + sessionId: string + messageId: string + requestSeq: number + mode: 'manifest_only' | 'trace_bound' + manifestRecord: DeepChatTapeViewManifestRecord + trace: DeepChatTapeReplayTraceSnapshot | null + entries: DeepChatTapeReplayEntrySnapshot[] + refs: { + manifestEntryId: number + includedEntryIds: number[] + excludedEntryIds: number[] + anchorEntryIds: number[] + } + hashes: { + manifestHash: string + sliceHash: string + } + createdAt: number +} +``` + +## Privacy + +Default export is metadata-only. It contains IDs, timestamps, names, source refs, and hashes. +Payload inclusion is opt-in and reads from existing storage: + +- tape entry payload/meta from `deepchat_tape_entries` +- trace headers/body from `deepchat_message_traces` + +No new raw-content storage path is introduced. diff --git a/docs/architecture/deepchat-tape-replay-contract/tasks.md b/docs/architecture/deepchat-tape-replay-contract/tasks.md new file mode 100644 index 000000000..dc27e75d4 --- /dev/null +++ b/docs/architecture/deepchat-tape-replay-contract/tasks.md @@ -0,0 +1,10 @@ +# DeepChat Tape Replay Contract - Tasks + +## Implementation Tasks + +- [x] T1: Add shared `DeepChatTapeReplaySlice` contract types. +- [x] T2: Add `DeepChatTapeService.exportReplaySlice()` with metadata-only defaults. +- [x] T3: Add route, presenter, and renderer client method. +- [x] T4: Add tests for latest selection, explicit `requestSeq`, missing manifest, default privacy, + and explicit payload inclusion. +- [x] T5: Run format, i18n, lint, typecheck, and focused tests. diff --git a/docs/architecture/deepchat-tape-view-assembler/plan.md b/docs/architecture/deepchat-tape-view-assembler/plan.md new file mode 100644 index 000000000..ced829cd6 --- /dev/null +++ b/docs/architecture/deepchat-tape-view-assembler/plan.md @@ -0,0 +1,59 @@ +# DeepChat Tape View Assembler - Plan + +## Architecture Decision + +Add `src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts` as the production context +assembly boundary. The assembler resolves the active `TapeViewPolicy` through the selector and +constrains input history to tape-effective records. + +## Flow + +```text +processMessage() + -> tapeService.ensureSessionTapeReady() + -> compaction uses tape-ready context history + -> TapeViewAssembler.buildChatView() + -> TapeViewPolicy selector + -> legacy_context_v1 policy + -> return messages + metadata + assembler provenance + -> runStreamForMessage() + -> ViewManifest append + +resumeAssistantMessage() + -> tapeService.ensureSessionTapeReady() + -> TapeViewAssembler.buildResumeView() + -> TapeViewPolicy selector + -> legacy_context_v1 policy + -> return messages + metadata + assembler provenance + -> runStreamForMessage() +``` + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts` | New assembler boundary. | +| `src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts` | Selection policy boundary used by the assembler. | +| `src/main/presenter/agentRuntimePresenter/index.ts` | Replace direct metadata builder calls with assembler calls. | +| `test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts` | Add chat/resume parity tests. | +| `docs/architecture/deepchat_tape_spec_v1.md` | Update current implementation path and owner table. | + +## Compatibility + +- Provider-bound messages stay byte-for-byte equivalent for existing tests. +- `contextBuilder.ts` remains available for unit tests and compatibility helpers. +- ViewManifest `contextBuilderVersion` remains `legacy-v1`. + +## Verification + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeService.test.ts +pnpm vitest run test/renderer/components/trace/TraceDialog.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck:node +pnpm run typecheck:web +pnpm vitest run --reporter=dot +``` diff --git a/docs/architecture/deepchat-tape-view-assembler/spec.md b/docs/architecture/deepchat-tape-view-assembler/spec.md new file mode 100644 index 000000000..10db478ae --- /dev/null +++ b/docs/architecture/deepchat-tape-view-assembler/spec.md @@ -0,0 +1,56 @@ +# DeepChat Tape View Assembler - Spec + +Status: implemented SDD. This goal replaces direct runtime context-builder calls with a Tape-owned +assembler boundary while preserving provider-bound message parity. + +## Problem + +ViewManifest and replay slices now make context decisions auditable. The runtime still calls +`buildContextWithMetadata()` and `buildResumeContextWithMetadata()` directly, so Tape remains an +observer around the production context path. The next architecture step needs a production +`TapeViewAssembler` boundary that owns context assembly inputs from the effective tape view. + +## Goals + +1. Route normal chat and resume context assembly through `TapeViewAssembler`. +2. Keep provider-bound `ChatMessage[]` identical to the existing context-builder output. +3. Keep `DeepChatTapeService` as the Tape boundary and use `ensureSessionTapeReady()` history + records as the assembly source. +4. Preserve ViewManifest shadow event behavior and replay export behavior. +5. Add parity tests proving assembler output matches the legacy context builder for chat and resume. + +## Non-Goals + +- Context selection policy changes. +- Compaction policy updates. +- Provider preflight or context-pressure recovery modifications. +- Adding embedding memory or cross-session recall. +- Removing the legacy `contextBuilder.ts` implementation. + +## Acceptance Criteria + +1. Runtime normal chat no longer calls `buildContextWithMetadata()` directly. +2. Runtime resume no longer calls `buildResumeContextWithMetadata()` directly. +3. `TapeViewAssembler` exposes chat and resume assembly methods with typed metadata. +4. The assembler uses tape-ready history records supplied by `DeepChatTapeService`. +5. Tests prove chat parity against `buildContextWithMetadata()`. +6. Tests prove resume parity against `buildResumeContextWithMetadata()`. +7. Existing ViewManifest, replay slice, trace, lint, typecheck, and full test suites pass. + +## Contract + +```ts +export interface TapeViewAssemblerResult { + messages: ChatMessage[] + metadata: ContextBuildMetadata + assemblerVersion: 'tape-view-assembler-v1' + historySource: 'tape_effective_view' + historyRecords: ChatMessageRecord[] + policyId: 'legacy_context_v1' + policyVersion: 1 + policySelectionReason: 'default' | 'requested' | 'fallback_default' | 'injected' +} +``` + +`contextBuilderVersion` in ViewManifest remains `legacy-v1` for this increment because the +underlying selection algorithm stays unchanged. diff --git a/docs/architecture/deepchat-tape-view-assembler/tasks.md b/docs/architecture/deepchat-tape-view-assembler/tasks.md new file mode 100644 index 000000000..19116d50e --- /dev/null +++ b/docs/architecture/deepchat-tape-view-assembler/tasks.md @@ -0,0 +1,9 @@ +# DeepChat Tape View Assembler - Tasks + +## Implementation Tasks + +- [x] T1: Add `TapeViewAssembler` types and chat/resume assembly functions. +- [x] T2: Replace runtime direct calls to metadata context builders. +- [x] T3: Add chat and resume parity tests. +- [x] T4: Update baseline Tape architecture doc. +- [x] T5: Run focused tests, format, i18n, lint, typecheck, and full tests. diff --git a/docs/architecture/deepchat-tape-view-manifest/plan.md b/docs/architecture/deepchat-tape-view-manifest/plan.md new file mode 100644 index 000000000..70e5fb447 --- /dev/null +++ b/docs/architecture/deepchat-tape-view-manifest/plan.md @@ -0,0 +1,179 @@ +# DeepChat Tape ViewManifest Shadow Mode - Plan + +## Architecture Decision + +Use the existing `DeepChatTapeService` and `deepchat_tape_entries` table. The first increment adds a +shadow manifest layer that observes context construction and request preflight results. It records +metadata as tape events and leaves production message assembly unchanged. + +`DeepChatTapeService` remains the single Tape service boundary. + +## Event Flow + +```text +AgentRuntimePresenter.processMessage() + -> tapeService.ensureSessionTapeReady() + -> messageStore.createUserMessage() + -> buildContextWithMetadata() + -> messageStore.createAssistantMessage() + -> runStreamForMessage() + -> processStream() + -> coreStream(requestMessages, requestTools) + -> preflightRequestContext() + -> optional recoverRequestContextPressure() + -> tapeViewManifest.assembleRequestManifest() + -> tapeService.appendViewManifest() + -> provider.coreStream() + -> optional request trace persists with matching requestSeq +``` + +Resume flow uses the same service with `taskType = "resume"`, `policy = "legacy_context_v1"`, and +`policyVersion = 1`. + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/shared/types/agent-interface.d.ts` or a new shared type file | Add public manifest result types for route/UI use. | +| `src/main/presenter/agentRuntimePresenter/tapeViewManifest.ts` | New pure assembler for manifest metadata and hashes. | +| `src/main/presenter/agentRuntimePresenter/tapeService.ts` | Append and list `view/assembled` events. | +| `src/main/presenter/agentRuntimePresenter/index.ts` | Call manifest assembly at initial context and request-level preflight points. | +| `src/shared/contracts/routes/sessions.routes.ts` | Extend the existing trace route output with manifest records. | +| `src/renderer/api/SessionClient.ts` | Add diagnostics and manifest client methods for message ID lookups. | +| `src/renderer/src/components/trace/TraceDialog.vue` | Add tabs for Request, View Manifest, Tape Entries, and Budget. | + +## Data Model + +Phase 1 stores manifests as tape events. This avoids a schema migration and uses the existing source +index: + +```text +deepchat_tape_entries + session_id = session id + kind = event + name = view/assembled + source_type = runtime_event + source_id = assistant message id + source_seq = request sequence + payload_json.data.manifest = DeepChatTapeViewManifest +``` + +The existing trace route resolves `messageId -> sessionId`, then returns both request traces and +matching tape manifest events. + +## Request Sequence + +`runStreamForMessage()` owns an in-memory request sequence counter for the assistant message: + +```text +assistant message m1 + requestSeq 1 -> initial provider request + requestSeq 2 -> provider request after a tool result + requestSeq 3 -> provider request after another tool result +``` + +The same sequence is used for the manifest and the request trace. If trace debug is disabled, the +manifest still records the sequence. + +## Hashing + +- `promptHash`: stable hash of provider-bound `ChatMessage[]` after preflight and recovery. +- `toolDefinitionsHash`: stable hash of provider-bound tool definitions. +- `manifestHash`: stable hash of the manifest with `manifestHash` omitted. + +Use deterministic JSON stringification for hash inputs. + +## Exclusion Reason Rules + +| Reason | Source | +| --- | --- | +| `before_summary_cursor` | Message order is below `summaryCursorOrderSeq`. | +| `compaction_indicator` | Message metadata marks a compaction indicator. | +| `pending_not_context_history` | Message status is pending outside the resume target. | +| `out_of_budget` | Turn was eligible but removed by token-budget selection. | +| `empty_after_formatting` | Record formatting produced zero provider messages. | +| `superseded` | Effective tape view replaced an older fact revision. | +| `retracted` | Effective tape view removed a message through a retraction event. | + +## UI Layout + +```text +TraceDialog ++-------------------------------------------------------------------+ +| Header | +| Title | ++-------------------------------------------------------------------+ +| Trace selector | ++-------------------------------------------------------------------+ +| Tabs: Request | View Manifest | Tape Entries | Budget | ++-------------------------------------------------------------------+ +| Request tab | +| Endpoint / provider / model | +| JSON editor | ++-------------------------------------------------------------------+ +| View Manifest tab | +| View id / policy / policy version / request seq | +| Included and excluded entry list | ++-------------------------------------------------------------------+ +| Tape Entries tab | +| Matching event and anchor refs | ++-------------------------------------------------------------------+ +| Budget tab | +| Context length / max tokens / estimated prompt tokens | ++-------------------------------------------------------------------+ +| Footer | ++-------------------------------------------------------------------+ +``` + +The dialog remains compact and developer-focused, with explanatory copy kept to empty and error +states. + +## Test Strategy + +| Test area | Required checks | +| --- | --- | +| Manifest assembler | Includes selected records, excludes cursor/budget drops, hashes deterministically. | +| Context parity | Existing `buildContext()` messages match manifest included refs for normal chat. | +| Resume parity | `buildResumeContext()` target and protected tail are represented. | +| Request sequence | Tool-loop provider calls create monotonically increasing request manifests. | +| Tape service | Append/list manifest events by message ID and request sequence. | +| Route/client | Legacy messages with an absent manifest return an empty list. | +| Renderer | Trace dialog renders Request tab and View Manifest empty/data states. | + +## Rollout + +1. Land shadow manifest assembly and tape persistence as headless runtime metadata. +2. Implement route/client support and tests. +3. Extend the trace dialog with diagnostic tabs. +4. Establish context parity coverage. +5. Use collected manifest data to design the later `TapeViewAssembler` production path. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Manifest diverges from actual provider request after preflight recovery. | Append request-level manifest after preflight and recovery for provider-bound context. | +| Storage growth from per-request events. | Store metadata and hashes only; avoid raw content duplication. | +| UI fails on old traces. | Treat missing manifest as an empty state. | +| Request sequence drifts from trace sequence. | Runtime owns the sequence and passes it to both manifest and trace persistence. | +| Context mapping misses synthetic system/new-user messages. | Represent them with `source = "synthetic"` and `entryId = null`. | + +## Verification Commands + +Run focused tests during implementation: + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeService.test.ts +pnpm vitest run test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts +pnpm vitest run test/renderer/components/trace/TraceDialog.test.ts +``` + +Before completing the feature, run: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +``` diff --git a/docs/architecture/deepchat-tape-view-manifest/spec.md b/docs/architecture/deepchat-tape-view-manifest/spec.md new file mode 100644 index 000000000..763a49904 --- /dev/null +++ b/docs/architecture/deepchat-tape-view-manifest/spec.md @@ -0,0 +1,231 @@ +# DeepChat Tape ViewManifest Shadow Mode - Spec + +Status: implemented SDD. This goal records the shadow-mode architecture and implementation tasks. + +## Problem + +DeepChat already persists append-only tape facts and request traces. The runtime lacks a persisted +context-selection decision for each LLM request. Debugging a long session currently requires +correlating message rows, compaction anchors, context-budget behavior, and provider request JSON by +hand. + +`ViewManifest` shadow mode records that decision while keeping the existing context builder as the +production path. + +## Current Code Baseline + +| Area | Current file | Role in this goal | +| --- | --- | --- | +| Tape table | `src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts` | Stores append-only events and anchors. | +| Tape service | `src/main/presenter/agentRuntimePresenter/tapeService.ts` | Existing Tape boundary; new manifest methods belong here or next to it. | +| Effective tape view | `src/main/presenter/agentRuntimePresenter/tapeEffectiveView.ts` | Reconstructs current message facts from tape entries. | +| Context builder | `src/main/presenter/agentRuntimePresenter/contextBuilder.ts` | Production path that shadow manifests must describe. | +| Runtime send path | `src/main/presenter/agentRuntimePresenter/index.ts` | Calls `ensureSessionTapeReady()`, `buildContext()`, and `runStreamForMessage()`. | +| Message trace | `src/main/presenter/sqlitePresenter/tables/deepchatMessageTraces.ts` | Existing request trace storage shown in the renderer. | +| Trace dialog | `src/renderer/src/components/trace/TraceDialog.vue` | First UI surface for ViewManifest inspection. | + +## Goals + +1. Persist one `ViewManifest` for every DeepChat LLM request attempt. +2. Keep `buildContext()` and request preflight behavior unchanged. +3. Explain included and excluded conversation facts with stable IDs and reasons. +4. Link each manifest to the assistant message and request sequence. +5. Support trace-dialog inspection while keeping raw prompt text out of the manifest. +6. Create parity tests that compare existing context output to manifest metadata. + +## Deferred Scope + +- Replacing `buildContext()` with a new assembler. +- Introducing a second TapeStore abstraction. +- Migrating old sessions eagerly. +- Adding embedding memory, topic clustering, or cross-session recall. +- Running live LLM replay in CI. +- Storing raw provider request bodies in tape events. + +## User Stories + +- As a developer, I can open a traced assistant message and see why each context entry was included + or excluded. +- As a maintainer, I can change context selection code and run tests that detect manifest/context + divergence. +- As an agent-runtime debugger, I can inspect the latest anchor, summary cursor, and token budget + used for a request. +- As a privacy-conscious user, I get manifest metadata while raw prompt content stays in its current + storage path. + +## Acceptance Criteria + +1. A normal chat turn appends a `view/assembled` tape event before the provider request is sent. +2. A resume turn appends a `view/assembled` tape event with `taskType = "resume"`. +3. A tool-loop provider request appends a request-level manifest with a monotonic `requestSeq` for + the assistant message. +4. If context-pressure recovery changes the provider request messages, a new manifest revision is + appended with `policy = "context_pressure_recovery_shadow"` and `policyVersion = null`. +5. The manifest records selected message IDs, source tape entry IDs when available, excluded message + IDs, exclusion reasons, token-budget inputs, prompt hash, and tool-definition hash. +6. The manifest stores IDs, hashes, token estimates, policies, policy versions, and reasons. Raw + user text, raw assistant text, raw tool output, image data, audio data, file content, API + headers, and API keys stay in existing storage paths. +7. Trace UI can show Request and View Manifest tabs for a traced assistant message. +8. Existing trace behavior remains compatible when a manifest is absent. +9. Tests prove that the selected history represented by the manifest matches `buildContext()` for + normal chat and resume paths. +10. Tests prove request-level manifest ordering across tool-loop provider calls. + +## ViewManifest Contract + +The first version is a shadow contract. It describes what the current runtime did. + +```ts +export type DeepChatTapeViewManifest = { + schemaVersion: 1 + viewId: string + sessionId: string + messageId: string + requestSeq: number + + taskType: 'chat' | 'resume' | 'tool_loop' + policy: + | 'legacy_context_v1' + | 'legacy_context_shadow' + | 'resume_shadow' + | 'tool_loop_shadow' + | 'context_pressure_recovery_shadow' + policyVersion: number | null + + contextBuilderVersion: 'legacy-v1' + latestEntryId: number + anchorEntryIds: number[] + + included: DeepChatTapeViewEntryRef[] + excluded: DeepChatTapeViewExcludedRef[] + + tokenBudget: { + contextLength: number + requestedMaxTokens: number + effectiveMaxTokens: number + reserveTokens: number + toolReserveTokens: number + estimatedPromptTokens: number + } + + hashes: { + promptHash: string + toolDefinitionsHash: string + manifestHash: string + } + + meta: { + providerId: string + modelId: string + summaryCursorOrderSeq: number + supportsVision: boolean + supportsAudioInput: boolean + traceDebugEnabled: boolean + } + + assembledAt: number +} + +export type DeepChatTapeViewEntryRef = { + entryId: number | null + messageId: string | null + orderSeq: number | null + role: 'system' | 'user' | 'assistant' | 'tool' | null + source: 'tape' | 'synthetic' + reason: + | 'system_prompt' + | 'selected_history' + | 'new_user_input' + | 'resume_target' + | 'tool_loop_message' +} + +export type DeepChatTapeViewExcludedRef = { + entryId: number | null + messageId: string | null + orderSeq: number | null + reason: + | 'before_summary_cursor' + | 'compaction_indicator' + | 'pending_not_context_history' + | 'out_of_budget' + | 'empty_after_formatting' + | 'superseded' + | 'retracted' +} +``` + +## Persistence Contract + +Manifests are append-only tape events: + +```json +{ + "kind": "event", + "name": "view/assembled", + "source_type": "runtime_event", + "source_id": "", + "source_seq": 1, + "payload_json": { + "name": "view/assembled", + "data": { + "manifest": "" + } + } +} +``` + +The manifest lookup key is `(sessionId, messageId, requestSeq)`. Existing tape indexes support this +through `source_type`, `source_id`, and `source_seq`. + +## UI Contract + +The first UI increment extends the existing trace dialog. + +```text ++-------------------------------------------------------------------+ +| Trace #1 Request | View Manifest | Tape Entries | Budget | ++-------------------------------------------------------------------+ +| View view_01 Policy legacy_context_v1 Version 1 | +| Anchor #42 Summary cursor 17 | ++------------------+------------------------------------------------+ +| Included | #43 user order=17 selected_history | +| | #44 assistant order=18 selected_history | +| Excluded | #1 user order=1 before_summary_cursor | +| | #29 assistant order=12 out_of_budget | ++------------------+------------------------------------------------+ +``` + +States: + +- Loading: dialog shows the existing spinner. +- Empty: Request tab stays available, View Manifest tab shows an empty state for this trace. +- Error: Request tab stays available during View Manifest loading failures. +- Legacy trace: the manifest tab explains that older traces have empty manifest state. + +## Privacy and Security + +- Store hashes and IDs in the manifest. +- Store raw provider request previews only in `deepchat_message_traces`. +- Reuse existing redaction for request traces. +- Keep file contents, image data, audio data, tool output, headers, and prompts in their existing + storage paths. + +## Compatibility + +- Old sessions keep lazy backfill through `DeepChatTapeService.ensureSessionTapeReady()`. +- Old traces render with an empty manifest state. +- Missing tape table remains a supported fallback for existing runtime code. +- The manifest feature preserves chat generation behavior, request ordering, and token-budget + decisions. +- Manifest append failures are logged and request execution continues. + +## Success Criteria + +- Shadow manifest generation is covered by unit tests for normal chat, resume, and tool-loop + request sequencing. +- Trace UI can display manifest data when present and degrade cleanly when absent. +- `buildContext()` output remains unchanged in existing tests. +- The SDD can support a later replacement phase where a real `TapeViewAssembler` becomes the + production path. diff --git a/docs/architecture/deepchat-tape-view-manifest/tasks.md b/docs/architecture/deepchat-tape-view-manifest/tasks.md new file mode 100644 index 000000000..7fd9301ef --- /dev/null +++ b/docs/architecture/deepchat-tape-view-manifest/tasks.md @@ -0,0 +1,28 @@ +# DeepChat Tape ViewManifest Shadow Mode - Tasks + +## Documentation + +- [x] T0: Create SDD folder and convert the broad Tape vision into the current implementation + direction. + +## Implementation Tasks + +- [x] T1: Add `DeepChatTapeViewManifest` shared types and a pure manifest hashing helper. +- [x] T2: Add `tapeViewManifest.ts` with pure assembly helpers for normal chat, resume, and + request-level provider calls. +- [x] T3: Extend `DeepChatTapeService` to append and list `view/assembled` events. +- [x] T4: Emit initial shadow manifests after `buildContext()` and `buildResumeContext()`. +- [x] T5: Emit request-level manifests inside `runStreamForMessage()` after preflight and + context-pressure recovery. +- [x] T6: Add typed route and renderer client method for manifests by message ID. +- [x] T7: Extend `TraceDialog.vue` with Request, View Manifest, Tape Entries, and Budget tabs. +- [x] T8: Add unit tests for manifest assembly, tape service list/append behavior, and route/client + compatibility. +- [x] T9: Add renderer tests for manifest tab loading, empty, error, and data states. +- [x] T10: Run format, i18n, lint, typecheck, and focused test suites. + +## Follow-up Tasks + +- [ ] F1: Evaluate whether manifest storage should get a dedicated table after real usage data. +- [ ] F2: Design production `TapeViewAssembler` replacement only after shadow parity is stable. +- [ ] F3: Define eval/replay export format from manifest slices. diff --git a/docs/architecture/deepchat-tape-view-policy/plan.md b/docs/architecture/deepchat-tape-view-policy/plan.md new file mode 100644 index 000000000..c81fcfcb4 --- /dev/null +++ b/docs/architecture/deepchat-tape-view-policy/plan.md @@ -0,0 +1,49 @@ +# DeepChat Tape View Policy - Plan + +## Architecture Decision + +Add `src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts`. The policy owns calls into +`contextBuilder.ts`; `TapeViewAssembler` owns production orchestration and provenance. + +## Flow + +```text +TapeViewAssembler.buildTapeChatView() + -> select default legacy_context_v1 policy + -> policy.buildChat() + -> return messages + metadata + policy id/version + +TapeViewAssembler.buildTapeResumeView() + -> select default legacy_context_v1 policy + -> policy.buildResume() + -> return messages + metadata + policy id/version +``` + +## Module Changes + +| Module | Change | +| --- | --- | +| `src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts` | New policy interface and legacy policy implementation. | +| `src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts` | Delegate selection to `TapeViewPolicy`. | +| `test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts` | Prove legacy policy parity. | +| `test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts` | Assert policy metadata and policy delegation. | +| `docs/architecture/deepchat_tape_spec_v1.md` | Record the new policy boundary. | + +## Compatibility + +- No runtime behavior change. +- `contextBuilder.ts` remains the legacy algorithm implementation. +- The default policy is fixed to `legacy_context_v1`. + +## Verification + +```bash +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck:node +pnpm run typecheck:web +pnpm vitest run --reporter=dot +``` diff --git a/docs/architecture/deepchat-tape-view-policy/spec.md b/docs/architecture/deepchat-tape-view-policy/spec.md new file mode 100644 index 000000000..ce5623a77 --- /dev/null +++ b/docs/architecture/deepchat-tape-view-policy/spec.md @@ -0,0 +1,49 @@ +# DeepChat Tape View Policy - Spec + +Status: implemented SDD. This goal extracts the current legacy context-selection algorithm behind a +Tape view policy interface. + +## Problem + +`TapeViewAssembler` is now the production context assembly entry. Tape context policy work needs a +typed policy boundary so new selection strategies can be introduced without changing runtime call +sites. + +## Goals + +1. Introduce a `TapeViewPolicy` interface for chat and resume assembly. +2. Implement `legacy_context_v1` as the first policy. +3. Keep provider-bound `ChatMessage[]` and metadata identical to the current assembler output. +4. Record policy id/version in `TapeViewAssemblerResult`. +5. Keep ViewManifest and replay slice behavior compatible. +6. Support registry-backed default policy resolution. + +## Non-Goals + +- Introducing a new context-selection algorithm. +- Changing compaction, preflight, or context-pressure recovery. +- Adding user-facing policy selection. +- Adding memory graph or embedding retrieval. + +## Acceptance Criteria + +1. `TapeViewAssembler` no longer imports `buildContextWithMetadata()` directly. +2. `TapeViewAssembler` no longer imports `buildResumeContextWithMetadata()` directly. +3. `legacy_context_v1` policy delegates to the current context builder. +4. Assembler results include `policyId = "legacy_context_v1"` and `policyVersion = 1`. +5. Tests prove policy output matches the legacy builder for chat and resume. +6. Tests prove assembler output still matches policy output. +7. Existing ViewManifest, replay, trace, lint, typecheck, and full test suites pass. + +## Contract + +```ts +export interface TapeViewPolicy { + id: 'legacy_context_v1' + version: 1 + buildChat(input: TapeChatViewPolicyInput): ContextBuildResult + buildResume(input: TapeResumeViewPolicyInput): ContextBuildResult +} +``` + +`legacy_context_v1` is the default policy for all DeepChat sessions in this increment. diff --git a/docs/architecture/deepchat-tape-view-policy/tasks.md b/docs/architecture/deepchat-tape-view-policy/tasks.md new file mode 100644 index 000000000..4b75d8e4f --- /dev/null +++ b/docs/architecture/deepchat-tape-view-policy/tasks.md @@ -0,0 +1,9 @@ +# DeepChat Tape View Policy - Tasks + +## Implementation Tasks + +- [x] T1: Add `TapeViewPolicy` interface and `legacy_context_v1` implementation. +- [x] T2: Update `TapeViewAssembler` to call policy interface and return policy metadata. +- [x] T3: Add policy parity tests and update assembler tests. +- [x] T4: Update baseline Tape architecture doc. +- [x] T5: Run focused tests, format, i18n, lint, typecheck, and full tests. diff --git a/docs/architecture/deepchat_tape_spec_v1.md b/docs/architecture/deepchat_tape_spec_v1.md new file mode 100644 index 000000000..2613b17a7 --- /dev/null +++ b/docs/architecture/deepchat_tape_spec_v1.md @@ -0,0 +1,154 @@ +# DeepChat Tape System - Implementation Baseline + +Status: current implementation direction. Active SDD goals are +[deepchat-tape-view-manifest](deepchat-tape-view-manifest/spec.md), +[deepchat-tape-replay-contract](deepchat-tape-replay-contract/spec.md), and +[deepchat-tape-view-assembler](deepchat-tape-view-assembler/spec.md), and +[deepchat-tape-view-policy](deepchat-tape-view-policy/spec.md), and +[deepchat-tape-policy-provenance](deepchat-tape-policy-provenance/spec.md), and +[deepchat-tape-policy-selector](deepchat-tape-policy-selector/spec.md). + +This document keeps the Tape vision aligned with the current DeepChat codebase. The implementation +path is: + +```text +Existing DeepChat runtime + -> existing DeepChatTapeService + -> ViewManifest shadow mode + -> Inspector and replay contracts + -> TapeViewAssembler production entry + -> TapeViewPolicy boundary + -> ViewManifest policy provenance + -> TapeViewPolicy registry and selector +``` + +## Current Baseline + +DeepChat already has the main Tape primitives. + +| Tape concept | Current owner | Notes | +| --- | --- | --- | +| Tape store | `DeepChatTapeEntriesTable` | Append-only `deepchat_tape_entries` with per-session monotonic `entry_id`. | +| Tape service | `DeepChatTapeService` | Backfills message facts, exposes info/search/anchors/handoff/fork metadata. | +| Message facts | `DeepChatMessageStore` + `tapeFacts.ts` | User, assistant, tool call, tool result, replacement, and retraction facts. | +| Anchor | `kind = "anchor"` entries | `session/start`, `compaction/*`, `handoff/*`, `auto_handoff/*`, `fork/start`. | +| Effective view | `tapeEffectiveView.ts` | Reconstructs current message records from append-only facts. | +| Context assembly | `tapeViewAssembler.ts` | Production entry that assembles provider context from tape-effective records. | +| View policy | `tapeViewPolicy.ts` | Registry and selector boundary; `legacy_context_v1` delegates to the current selector. | +| Context selection | `contextBuilder.ts` | Legacy token-budget selector used by `legacy_context_v1`. | +| Request trace | `deepchat_message_traces` | Stores redacted provider request previews for the trace dialog. | +| Agent tools | `agentTapeTools.ts` | Exposes `tape_info`, `tape_search`, `tape_anchors`, `tape_handoff`. | + +The first implementation step uses this baseline as the single runtime path. `DeepChatTapeService` +remains the Tape service boundary. + +## Active SDD + +The active SDD folders are: + +```text +docs/architecture/deepchat-tape-view-manifest/ +├── spec.md +├── plan.md +└── tasks.md +docs/architecture/deepchat-tape-replay-contract/ +├── spec.md +├── plan.md +└── tasks.md +docs/architecture/deepchat-tape-view-assembler/ +├── spec.md +├── plan.md +└── tasks.md +docs/architecture/deepchat-tape-view-policy/ +├── spec.md +├── plan.md +└── tasks.md +docs/architecture/deepchat-tape-policy-provenance/ +├── spec.md +├── plan.md +└── tasks.md +docs/architecture/deepchat-tape-policy-selector/ +├── spec.md +├── plan.md +└── tasks.md +``` + +The current SDD scopes are `Existing TapeService + ViewManifest shadow mode`, replay/export +contracts, `TapeViewAssembler` as the production context assembly entry, and `TapeViewPolicy` as +the policy replacement boundary, `ViewManifest` policy provenance, and policy selector registry. + +## Scope Boundary + +### In scope + +- Generate a `ViewManifest` for each DeepChat LLM request while `TapeViewAssembler` remains + provider-message equivalent with the legacy context selector. +- Persist manifests as `view/assembled` tape events. +- Link manifests to request traces by `messageId` and request sequence. +- Add Inspector support that explains included/excluded context entries. +- Add parity tests proving shadow manifests describe the same context that the existing runtime + sends. +- Export replay slices from manifest, trace metadata, and referenced tape entries. +- Route chat and resume production context assembly through `TapeViewAssembler`. +- Route context selection through `TapeViewPolicy` with `legacy_context_v1` as the default policy. +- Persist the active Tape view policy id and version in initial chat and resume manifests. +- Resolve active Tape view policies through a registry-backed selector. + +### Deferred scope for the first increment + +- Creating a separate TapeStore abstraction. +- Memory graph retrieval, embedding-backed topic clustering, and cross-session recall. +- Live LLM replay in CI. +- Full eval pipeline and training exports. + +## Implementation Rules + +1. Keep `DeepChatTapeService` as the Tape service boundary. +2. Store manifest data as append-only tape events. +3. Keep raw prompt and provider request bodies in existing trace storage only. +4. Store IDs, hashes, token estimates, policy names, policy versions, and exclusion reasons in the + manifest. +5. Keep old sessions compatible through existing lazy backfill and bootstrap anchors. +6. Treat `ViewManifest` as an explanation and regression artifact until parity is proven. + +## Target Flow + +```text +sendMessage / resume + -> ensureSessionTapeReady() + -> TapeViewAssembler.buildChatView() / buildResumeView() + -> TapeViewPolicy selector + -> legacy_context_v1 TapeViewPolicy + -> assemble ViewManifest shadow event with legacy_context_v1@1 provenance + -> runStreamForMessage() + -> preflight provider request + -> assemble request-level ViewManifest revision if context changed + -> provider.coreStream() + -> optional request trace linked to ViewManifest + -> message/tool facts appended +``` + +## Inspector Shape + +```text ++-------------------------------------------------------------------+ +| Trace #1 Request | View Manifest | Tape Entries | Budget | ++-------------------------------------------------------------------+ +| Provider openai Model gpt-4.1 | +| View view_01 Policy legacy_context_v1@1 | ++-----------------------------+-------------------------------------+ +| Included | message/user #12 | +| | message/assistant #13 | +| Excluded | #1-#8 compressed by anchor #42 | +| Budget | 23k estimated / 64k context | ++-----------------------------+-------------------------------------+ +``` + +## Expected Benefits + +- Every LLM request can explain which conversation facts were included. +- Context compaction and handoff behavior becomes auditable through anchor and manifest metadata. +- Trace debugging gains policy-level context instead of only raw request JSON. +- Future context-policy changes get a parity baseline. +- Evaluation and replay can be derived from existing runtime facts after the manifest contract is + stable. diff --git a/docs/issues/deepchat-tape-view-manifest-pr-review/plan.md b/docs/issues/deepchat-tape-view-manifest-pr-review/plan.md new file mode 100644 index 000000000..657255637 --- /dev/null +++ b/docs/issues/deepchat-tape-view-manifest-pr-review/plan.md @@ -0,0 +1,44 @@ +# Tape ViewManifest PR Review Fixes - Plan + +## Approach + +Apply the PR review fixes in place, keeping the existing Tape architecture and presenter boundaries intact. + +## Changes + +| Area | Plan | +| --- | --- | +| `contextBuilder.ts` | Make resume `out_of_budget` exclusions only include emitted records that were not selected. | +| `agentRuntimePresenter/index.ts` | Carry recovered `summaryCursorOrderSeq` into manifest append; refresh tape history after resume compaction before building the resume view. | +| `agentSessionPresenter/index.ts` | Wrap optional manifest/replay delegation in `try/catch`, log warnings, and return graceful fallbacks. | +| `SessionClient.ts` | Normalize `result.manifests` to an array before returning diagnostics. | +| `TraceDialog.vue` | When a request sequence is selected, only show matching trace/manifest data. | +| `routes.ts` | Replace broad route catalog annotation with `satisfies Record`. | +| i18n `traceDialog.json` | Translate new diagnostic keys for non-English locales and convert Traditional Chinese files. | +| SDD docs | Keep this issue SDD current and address small doc nitpicks. | + +## Compatibility + +- No database or IPC schema changes. +- ViewManifest remains schema version 1. +- Existing sessions without manifests continue to return empty diagnostics. + +## Test Strategy + +Run required project checks: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +``` + +Run focused checks where practical: + +```bash +pnpm run typecheck +pnpm vitest run test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts +pnpm vitest run test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts +pnpm vitest run test/renderer/components/trace/TraceDialog.test.ts +``` diff --git a/docs/issues/deepchat-tape-view-manifest-pr-review/spec.md b/docs/issues/deepchat-tape-view-manifest-pr-review/spec.md new file mode 100644 index 000000000..3ab6b0799 --- /dev/null +++ b/docs/issues/deepchat-tape-view-manifest-pr-review/spec.md @@ -0,0 +1,45 @@ +# Tape ViewManifest PR Review Fixes - Spec + +Status: active issue-fix SDD for PR #1767 review follow-up. + +## Problem + +PR review identified correctness and localization issues in the Tape ViewManifest flow. Some diagnostics can fail hard instead of degrading gracefully, request/manifest diagnostics can show mismatched request sequences, manifest provenance can record stale summary cursors after context-pressure recovery, resume view assembly can use stale tape history after compaction, route-contract typing loses literal key precision, and newly added TraceDialog labels are not properly localized. + +## Goals + +1. Fix still-valid CodeRabbit review findings for PR #1767 with minimal changes. +2. Preserve Tape ViewManifest and replay-slice contracts while correcting provenance and diagnostics behavior. +3. Ensure TraceDialog diagnostic strings are localized for every supported non-English locale touched by the PR. +4. Keep route-contract literal key inference intact. +5. Commit the fixes locally without pushing. + +## Acceptance Criteria + +1. Resume context assembly refreshes tape history after compaction resolution. +2. Context-pressure recovery returns and records the recovered summary cursor in appended manifests. +3. Excluded context metadata cannot classify the same resume record as both `empty_after_formatting` and `out_of_budget`. +4. Message view-manifest listing and replay-slice export return `[]`/`null` when agent resolution fails. +5. Renderer session client always returns an array for manifest diagnostics. +6. TraceDialog returns `null` instead of falling back to another request when a selected request sequence is missing from either traces or manifests. +7. `DEEPCHAT_ROUTE_CATALOG` uses `satisfies Record` so route names remain a literal union. +8. TraceDialog diagnostic labels are translated in supported non-English locale files, including Traditional Chinese variants. +9. Review nitpicks that are small and local are addressed without broad refactors. +10. `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` pass or any blocker is documented. + +## Constraints + +- Do not push changes. +- Do not weaken authentication, authorization, or validation. +- Avoid unrelated refactors and preserve existing presenter boundaries. +- Keep ViewManifest schema compatible. + +## Non-Goals + +- Redesigning Tape ViewManifest or replay contracts. +- Adding new context-selection policies. +- Reworking the full i18n pipeline beyond the reviewed TraceDialog keys. + +## Open Questions + +None. diff --git a/docs/issues/deepchat-tape-view-manifest-pr-review/tasks.md b/docs/issues/deepchat-tape-view-manifest-pr-review/tasks.md new file mode 100644 index 000000000..9fc46b6a6 --- /dev/null +++ b/docs/issues/deepchat-tape-view-manifest-pr-review/tasks.md @@ -0,0 +1,10 @@ +# Tape ViewManifest PR Review Fixes - Tasks + +- [x] T1: Inspect PR review findings and current branch state. +- [x] T2: Fix runtime provenance and resume-history correctness issues. +- [x] T3: Harden diagnostics/replay APIs and renderer request selection. +- [x] T4: Restore route catalog type precision. +- [x] T5: Translate TraceDialog diagnostic labels in non-English locales. +- [x] T6: Address local documentation/nitpick comments. +- [x] T7: Run format, i18n, lint, and focused validation. +- [x] T8: Stage intentional files and create a local commit without pushing. diff --git a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts index 93711a51a..b88edd218 100644 --- a/src/main/presenter/agentRuntimePresenter/contextBuilder.ts +++ b/src/main/presenter/agentRuntimePresenter/contextBuilder.ts @@ -10,6 +10,10 @@ import type { MessageMetadata, SendMessageInput } from '@shared/types/agent-interface' +import type { + DeepChatTapeViewEntryReason, + DeepChatTapeViewExcludedReason +} from '@shared/types/tape-view-manifest' import type { DeepChatMessageStore } from './messageStore' const IMAGE_TOKEN_ESTIMATE = 512 @@ -43,6 +47,27 @@ export type HistoryTurn = { tokens: number } +export type ContextIncludedRecord = { + record: ChatMessageRecord + reason: DeepChatTapeViewEntryReason +} + +export type ContextExcludedRecord = { + record: ChatMessageRecord + reason: DeepChatTapeViewExcludedReason +} + +export type ContextBuildMetadata = { + includedRecords: ContextIncludedRecord[] + excludedRecords: ContextExcludedRecord[] + includesSystemPrompt: boolean +} + +export type ContextBuildResult = { + messages: ChatMessage[] + metadata: ContextBuildMetadata +} + function parseProviderOptionsJson( value: string | undefined ): ChatMessageProviderOptions | undefined { @@ -147,7 +172,7 @@ function parseUserRecordContent(content: string): SendMessageInput { } } -function isCompactionRecord(record: ChatMessageRecord): boolean { +export function isCompactionRecord(record: ChatMessageRecord): boolean { try { const metadata = JSON.parse(record.metadata) as MessageMetadata return metadata.messageType === 'compaction' @@ -860,18 +885,26 @@ function selectTurnHistory( availableTokens: number, fallbackProtectedTurnCount: number ): ChatMessage[] { + return flattenTurns(selectTurnHistoryTurns(turns, availableTokens, fallbackProtectedTurnCount)) +} + +function selectTurnHistoryTurns( + turns: T[], + availableTokens: number, + fallbackProtectedTurnCount: number +): T[] { if (turns.length === 0) { return [] } const protectedCount = Math.max(0, Math.min(fallbackProtectedTurnCount, turns.length)) if (availableTokens <= 0) { - return protectedCount > 0 ? flattenTurns(turns.slice(-protectedCount)) : [] + return protectedCount > 0 ? turns.slice(-protectedCount) : [] } let total = turns.reduce((sum, turn) => sum + turn.tokens, 0) if (total <= availableTokens) { - return flattenTurns(turns) + return turns } const remainingTurns = [...turns] @@ -886,10 +919,17 @@ function selectTurnHistory( estimateMessagesTokens(flattened) <= availableTokens || remainingTurns.length <= protectedCount ) { - return flattened + return remainingTurns } - return truncateContext(flattened, availableTokens) + const truncatedMessages = truncateContext(flattened, availableTokens) + return [ + { + ...remainingTurns[0], + messages: truncatedMessages, + tokens: estimateMessagesTokens(truncatedMessages) + } + ] } function filterRecordsFromCursor( @@ -910,12 +950,33 @@ export function buildContext( supportsVision: boolean = false, options: ContextBuildOptions = {} ): ChatMessage[] { + return buildContextWithMetadata( + sessionId, + newUserContent, + systemPrompt, + contextLength, + reserveTokens, + messageStore, + supportsVision, + options + ).messages +} + +export function buildContextWithMetadata( + sessionId: string, + newUserContent: string | SendMessageInput, + systemPrompt: string, + contextLength: number, + reserveTokens: number, + messageStore: DeepChatMessageStore, + supportsVision: boolean = false, + options: ContextBuildOptions = {} +): ContextBuildResult { const supportsAudioInput = options.supportsAudioInput === true const candidateRecords = options.historyRecords ?? messageStore.getMessages(sessionId) - const historyRecords = filterRecordsFromCursor( - candidateRecords.filter(isContextHistoryRecord), - options.summaryCursorOrderSeq ?? 1 - ) + const contextCandidateRecords = candidateRecords.filter(isContextHistoryRecord) + const cursor = Math.max(1, options.summaryCursorOrderSeq ?? 1) + const historyRecords = filterRecordsFromCursor(contextCandidateRecords, cursor) const historyTurns = buildHistoryTurns( historyRecords, supportsVision, @@ -933,11 +994,18 @@ export function buildContext( newUserTokens - reserveTokens - (options.extraReserveTokens ?? 0) - const selectedHistory = selectTurnHistory( + const selectedTurns = selectTurnHistoryTurns( historyTurns, available, options.fallbackProtectedTurnCount ?? 0 ) + const selectedHistory = flattenTurns(selectedTurns) + const selectedRecordIds = new Set( + selectedTurns.flatMap((turn) => turn.records.map((record) => record.id)) + ) + const emittedRecordIds = new Set( + historyTurns.flatMap((turn) => turn.records.map((record) => record.id)) + ) const messages: ChatMessage[] = [] if (systemPrompt) { @@ -947,7 +1015,40 @@ export function buildContext( if (hasPromptMessageContent(newUserMessage)) { messages.push(newUserMessage) } - return messages + const excludedRecords: ContextExcludedRecord[] = [ + ...contextCandidateRecords + .filter((record) => record.orderSeq < cursor) + .map((record) => ({ + record, + reason: 'before_summary_cursor' as const + })), + ...historyRecords + .filter((record) => !emittedRecordIds.has(record.id)) + .map((record) => ({ + record, + reason: 'empty_after_formatting' as const + })), + ...historyRecords + .filter((record) => emittedRecordIds.has(record.id) && !selectedRecordIds.has(record.id)) + .map((record) => ({ + record, + reason: 'out_of_budget' as const + })) + ] + + return { + messages, + metadata: { + includedRecords: selectedTurns.flatMap((turn) => + turn.records.map((record) => ({ + record, + reason: 'selected_history' as const + })) + ), + excludedRecords, + includesSystemPrompt: Boolean(systemPrompt) + } + } } export function fitMessagesToContextWindow( @@ -1001,6 +1102,28 @@ export function buildResumeContext( supportsVision: boolean = false, options: ContextBuildOptions = {} ): ChatMessage[] { + return buildResumeContextWithMetadata( + sessionId, + assistantMessageId, + systemPrompt, + contextLength, + reserveTokens, + messageStore, + supportsVision, + options + ).messages +} + +export function buildResumeContextWithMetadata( + sessionId: string, + assistantMessageId: string, + systemPrompt: string, + contextLength: number, + reserveTokens: number, + messageStore: DeepChatMessageStore, + supportsVision: boolean = false, + options: ContextBuildOptions = {} +): ContextBuildResult { const supportsAudioInput = options.supportsAudioInput === true const allMessages = options.historyRecords ?? messageStore.getMessages(sessionId) const targetMessage = allMessages.find((message) => message.id === assistantMessageId) @@ -1030,16 +1153,65 @@ export function buildResumeContext( const systemPromptTokens = systemPrompt ? approximateTokenSize(systemPrompt) : 0 const available = contextLength - systemPromptTokens - reserveTokens - (options.extraReserveTokens ?? 0) - const selectedHistory = selectTurnHistory( + const selectedTurns = selectTurnHistoryTurns( historyTurns, available, options.fallbackProtectedTurnCount ?? 1 ) + const selectedHistory = flattenTurns(selectedTurns) + const selectedRecordIds = new Set( + selectedTurns.flatMap((turn) => turn.records.map((record) => record.id)) + ) + const emittedRecordIds = new Set( + historyTurns.flatMap((turn) => turn.records.map((record) => record.id)) + ) const messages: ChatMessage[] = [] if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }) } messages.push(...selectedHistory) - return messages + const excludedRecords: ContextExcludedRecord[] = [ + ...allMessages + .filter( + (record) => + record.id !== assistantMessageId && + isContextHistoryRecord(record) && + record.orderSeq < cursor && + (targetOrderSeq === undefined || record.orderSeq <= targetOrderSeq) + ) + .map((record) => ({ + record, + reason: 'before_summary_cursor' as const + })), + ...historyRecords + .filter((record) => !emittedRecordIds.has(record.id)) + .map((record) => ({ + record, + reason: 'empty_after_formatting' as const + })), + ...historyRecords + .filter((record) => emittedRecordIds.has(record.id) && !selectedRecordIds.has(record.id)) + .map((record) => ({ + record, + reason: 'out_of_budget' as const + })) + ] + + return { + messages, + metadata: { + includedRecords: selectedTurns.flatMap((turn) => + turn.records.map((record) => ({ + record, + reason: + record.id === assistantMessageId + ? ('resume_target' as const) + : ('selected_history' as const) + })) + ), + excludedRecords, + includesSystemPrompt: Boolean(systemPrompt) + } + } } diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index d5ebbcba4..a46fd9a9b 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -29,6 +29,10 @@ import type { } from '@shared/types/agent-interface' import type { MCPToolCall, MCPToolResponse, ToolCallImagePreview } from '@shared/types/core/mcp' import type { ChatMessage } from '@shared/types/core/chat-message' +import type { + DeepChatTapeReplayExportOptions, + DeepChatTapeReplaySlice +} from '@shared/types/tape-replay' import type { IConfigPresenter, ILlmProviderPresenter, @@ -80,7 +84,12 @@ import { buildRuntimeCapabilitiesPrompt, buildSystemEnvPrompt } from '@/lib/agentRuntime/systemEnvPromptBuilder' -import { buildContext, buildResumeContext, isContextHistoryRecord } from './contextBuilder' +import type { ContextBuildMetadata } from './contextBuilder' +import { + buildTapeChatView, + buildTapeResumeView, + getTapeContextHistoryRecords +} from './tapeViewAssembler' import { capAgentDefaultMaxTokens, capAgentRequestMaxTokens, @@ -98,6 +107,14 @@ import { import { buildPersistableMessageTracePayload } from './messageTracePayload' import { buildTerminalErrorBlocks, DeepChatMessageStore } from './messageStore' import { DeepChatTapeService } from './tapeService' +import { + buildExcludedRefs, + buildIncludedRefs, + buildSyntheticRequestRefs, + createTapeViewManifest, + resolveTapeViewManifestPolicy, + type TapeViewContextSelection +} from './tapeViewManifest' import { PendingInputCoordinator } from './pendingInputCoordinator' import { DeepChatPendingInputStore } from './pendingInputStore' import { processStream } from './process' @@ -106,6 +123,12 @@ import { DeepChatSessionStore, type SessionSummaryState } from './sessionStore' import type { InterleavedReasoningConfig, PendingToolInteraction, ProcessResult } from './types' import { ToolOutputGuard } from './toolOutputGuard' import type { ProviderRequestTracePayload } from '../llmProviderPresenter/requestTrace' +import type { + DeepChatTapeViewPolicy, + DeepChatTapeViewManifestRecord, + DeepChatTapeViewTaskType, + DeepChatTapeViewTokenBudget +} from '@shared/types/tape-view-manifest' import type { NewSessionHooksBridge } from '../hooksNotifications/newSessionBridge' import { providerDbLoader } from '../configPresenter/providerDbLoader' import { resolveSessionVisionTarget } from '../vision/sessionVisionResolver' @@ -130,6 +153,17 @@ type PendingInteractionEntry = { type ProcessPendingInputSource = PendingInputEnqueueSource | 'steer' +type PendingTapeViewContext = { + taskType: DeepChatTapeViewTaskType + policy: DeepChatTapeViewPolicy + policyVersion?: number | null + selection: TapeViewContextSelection + summaryCursorOrderSeq: number + supportsVision: boolean + supportsAudioInput: boolean + traceDebugEnabled: boolean +} + type DeferredToolExecutionResult = { responseText: string isError: boolean @@ -275,6 +309,18 @@ const createAbortError = (): Error => { return error } +function buildTapeViewSelection( + metadata: ContextBuildMetadata, + newUserMessageId?: string | null +): TapeViewContextSelection { + return { + includedRecords: metadata.includedRecords, + excludedRecords: metadata.excludedRecords, + includesSystemPrompt: metadata.includesSystemPrompt, + newUserMessageId + } +} + export class AgentRuntimePresenter implements IAgentImplementation { private readonly llmProviderPresenter: ILlmProviderPresenter private readonly configPresenter: IConfigPresenter @@ -704,7 +750,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { ) this.throwIfAbortRequested(preStreamAbortSignal) const tapeReady = this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) - const historyRecords = tapeReady.historyRecords.filter(isContextHistoryRecord) + const historyRecords = getTapeContextHistoryRecords(tapeReady.historyRecords) const userContent: UserMessageContent = { text: normalizedInput.text, files: normalizedInput.files || [], @@ -783,24 +829,25 @@ export class AgentRuntimePresenter implements IAgentImplementation { appendSummarySection(baseSystemPrompt, summaryState.summaryText), this.sessionStore.getReconstructionAnchorPromptState(sessionId) ) - const messages = buildContext( + const contextBuild = buildTapeChatView({ sessionId, - normalizedInput, + newUserContent: normalizedInput, systemPrompt, - contextBudgetLength, - maxTokens, - this.messageStore, + contextLength: contextBudgetLength, + reserveTokens: maxTokens, + messageStore: this.messageStore, supportsVision, - { + historyRecords, + options: { summaryCursorOrderSeq: summaryState.summaryCursorOrderSeq, - historyRecords, supportsAudioInput, extraReserveTokens: toolReserveTokens, preserveInterleavedReasoning: interleavedReasoning.preserveReasoningContent, preserveEmptyInterleavedReasoning: interleavedReasoning.preserveEmptyReasoningContent === true } - ) + }) + const messages = contextBuild.messages const assistantOrderSeq = this.messageStore.getNextOrderSeq(sessionId) assistantMessageId = this.messageStore.createAssistantMessage(sessionId, assistantOrderSeq) @@ -824,7 +871,17 @@ export class AgentRuntimePresenter implements IAgentImplementation { promptPreview: normalizedInput.text, tools, baseSystemPrompt, - interleavedReasoning + interleavedReasoning, + viewContext: { + taskType: 'chat', + policy: contextBuild.policyId, + policyVersion: contextBuild.policyVersion, + selection: buildTapeViewSelection(contextBuild.metadata, userMessageId), + summaryCursorOrderSeq: summaryState.summaryCursorOrderSeq, + supportsVision, + supportsAudioInput, + traceDebugEnabled: this.configPresenter.getSetting('traceDebugEnabled') === true + } }) if (context?.pendingQueueItemId && !consumedPendingQueueItem) { if (pendingInputSource === 'queue' || pendingInputSource === 'steer') { @@ -1952,6 +2009,23 @@ export class AgentRuntimePresenter implements IAgentImplementation { return this.toTapeAnchorResult(row) } + async listMessageViewManifests( + sessionId: string, + messageId: string + ): Promise { + this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + return this.tapeService.listViewManifestsByMessage(sessionId, messageId) + } + + async exportMessageTapeReplaySlice( + sessionId: string, + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise { + this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + return this.tapeService.exportReplaySlice(sessionId, messageId, options) + } + async mergeSubagentTape( parentSessionId: string, childSessionId: string, @@ -2235,6 +2309,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { initialBlocks?: AssistantMessageBlock[] promptPreview?: string interleavedReasoning?: InterleavedReasoningConfig + viewContext?: PendingTapeViewContext }): Promise<{ runId: string; result: ProcessResult }> { const { sessionId, @@ -2245,7 +2320,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { baseSystemPrompt, initialBlocks, promptPreview, - interleavedReasoning: providedInterleavedReasoning + interleavedReasoning: providedInterleavedReasoning, + viewContext } = args const state = this.runtimeState.get(sessionId) if (!state) { @@ -2309,6 +2385,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { const recoverContextPressure = this.recoverRequestContextPressure.bind(this) const replaceLeadingSystemPromptInPlace = this.replaceLeadingSystemPromptInPlace.bind(this) const persistMessageTrace = this.persistMessageTrace.bind(this) + const appendTapeViewManifest = this.appendTapeViewManifest.bind(this) if (traceEnabled) { const traceAwareConfig = modelConfig as ModelConfig & { requestTraceContext?: { @@ -2342,6 +2419,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { const rateLimitMessageId = this.buildRateLimitStreamMessageId(activeGeneration.runId) const emitRateLimitWaitingMessage = this.emitRateLimitWaitingMessage.bind(this) const clearRateLimitWaitingMessage = this.clearRateLimitWaitingMessage.bind(this) + let requestSeq = 0 try { this.dispatchHook('SessionStart', { @@ -2375,6 +2453,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { try { let providerMessages = requestMessages let providerMaxTokens = requestMaxTokens + let recoveredFromContextPressure = false + let manifestSummaryCursorOrderSeq = viewContext?.summaryCursorOrderSeq ?? 1 const isTtsRequest = isTtsModelConfig(requestModelConfig) || isTtsModelId(requestModelId) const effectiveRequestTools: MCPToolDefinition[] = isTtsRequest ? [] : requestTools @@ -2405,6 +2485,10 @@ export class AgentRuntimePresenter implements IAgentImplementation { minimumProtectedTailCount: 0, signal: abortController.signal }) + recoveredFromContextPressure = true + if (recovered.summaryCursorOrderSeq !== undefined) { + manifestSummaryCursorOrderSeq = recovered.summaryCursorOrderSeq + } requestMessages.splice(0, requestMessages.length, ...recovered.messages) if (recovered.systemPrompt) { replaceLeadingSystemPromptInPlace(requestMessages, recovered.systemPrompt) @@ -2427,6 +2511,42 @@ export class AgentRuntimePresenter implements IAgentImplementation { throw new Error('Request was not sent because the prompt became empty.') } + requestSeq += 1 + const isInitialViewRequest = requestSeq === 1 && Boolean(viewContext) + const manifestPolicy = resolveTapeViewManifestPolicy({ + recoveredFromContextPressure, + isInitialViewRequest, + viewPolicy: viewContext?.policy, + viewPolicyVersion: viewContext?.policyVersion + }) + appendTapeViewManifest({ + sessionId, + messageId, + requestSeq, + taskType: isInitialViewRequest ? viewContext!.taskType : 'tool_loop', + policy: manifestPolicy.policy, + policyVersion: manifestPolicy.policyVersion, + messages: providerMessages, + tools: effectiveRequestTools, + tokenBudget: { + contextLength: requestModelConfig.contextLength ?? contextBudgetLength, + requestedMaxTokens: requestMaxTokens, + effectiveMaxTokens: providerMaxTokens, + reserveTokens: requestMaxTokens, + toolReserveTokens: estimateToolReserveTokens(effectiveRequestTools) + }, + providerId: state.providerId, + modelId: requestModelId, + selection: + isInitialViewRequest && !recoveredFromContextPressure + ? viewContext!.selection + : undefined, + summaryCursorOrderSeq: manifestSummaryCursorOrderSeq, + supportsVision: viewContext?.supportsVision ?? supportsVision, + supportsAudioInput: viewContext?.supportsAudioInput ?? supportsAudioInput, + traceDebugEnabled: viewContext?.traceDebugEnabled ?? traceEnabled + }) + await llmProviderPresenter.executeWithRateLimit(state.providerId, { signal: abortController.signal, onQueued: (snapshot) => { @@ -2578,6 +2698,59 @@ export class AgentRuntimePresenter implements IAgentImplementation { } } + private appendTapeViewManifest(params: { + sessionId: string + messageId: string + requestSeq: number + taskType: DeepChatTapeViewTaskType + policy: DeepChatTapeViewPolicy + policyVersion?: number | null + messages: ChatMessage[] + tools: MCPToolDefinition[] + tokenBudget: Omit + providerId: string + modelId: string + selection?: TapeViewContextSelection + summaryCursorOrderSeq: number + supportsVision: boolean + supportsAudioInput: boolean + traceDebugEnabled: boolean + }): void { + try { + const sourceMaps = this.tapeService.getViewManifestSourceMaps(params.sessionId) + const manifest = createTapeViewManifest({ + sessionId: params.sessionId, + messageId: params.messageId, + requestSeq: params.requestSeq, + taskType: params.taskType, + policy: params.policy, + policyVersion: params.policyVersion ?? null, + messages: params.messages, + tools: params.tools, + latestEntryId: sourceMaps.latestEntryId, + anchorEntryIds: sourceMaps.anchorEntryIds, + included: params.selection + ? buildIncludedRefs(params.selection, sourceMaps) + : buildSyntheticRequestRefs(params.messages), + excluded: params.selection ? buildExcludedRefs(params.selection, sourceMaps) : [], + tokenBudget: params.tokenBudget, + providerId: params.providerId, + modelId: params.modelId, + summaryCursorOrderSeq: params.summaryCursorOrderSeq, + supportsVision: params.supportsVision, + supportsAudioInput: params.supportsAudioInput, + traceDebugEnabled: params.traceDebugEnabled + }) + this.tapeService.appendViewManifest(manifest) + } catch (error) { + logger.warn( + `[DeepChatAgent] Failed to persist tape view manifest: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + } + private async recoverRequestContextPressure(params: { sessionId: string providerId: string @@ -2592,7 +2765,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { interleavedReasoning: InterleavedReasoningConfig minimumProtectedTailCount: number signal: AbortSignal - }): Promise<{ messages: ChatMessage[]; systemPrompt?: string }> { + }): Promise<{ messages: ChatMessage[]; systemPrompt?: string; summaryCursorOrderSeq?: number }> { let messages = params.requestMessages const systemPromptBase = params.baseSystemPrompt ?? this.getLeadingSystemPrompt(params.requestMessages) ?? '' @@ -2635,7 +2808,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { reserveTokens: params.requestedMaxTokens + estimateToolReserveTokens(params.tools), minimumProtectedTailCount: params.minimumProtectedTailCount }), - systemPrompt + systemPrompt, + summaryCursorOrderSeq: summaryState.summaryCursorOrderSeq } } @@ -3027,21 +3201,22 @@ export class AgentRuntimePresenter implements IAgentImplementation { }) : this.sessionStore.getSummaryState(sessionId) this.throwIfAbortRequested(preStreamAbortSignal) + const resumeTapeReady = this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) const systemPrompt = appendReconstructionAnchorStateSection( appendSummarySection(baseSystemPrompt, summaryState.summaryText), this.sessionStore.getReconstructionAnchorPromptState(sessionId) ) - let resumeContext = buildResumeContext( + const resumeContextBuild = buildTapeResumeView({ sessionId, - messageId, + assistantMessageId: messageId, systemPrompt, - contextBudgetLength, - maxTokens, - this.messageStore, - this.supportsVision(state.providerId, state.modelId), - { + contextLength: contextBudgetLength, + reserveTokens: maxTokens, + messageStore: this.messageStore, + supportsVision: this.supportsVision(state.providerId, state.modelId), + historyRecords: resumeTapeReady.historyRecords, + options: { summaryCursorOrderSeq: summaryState.summaryCursorOrderSeq, - historyRecords: tapeReady.historyRecords, fallbackProtectedTurnCount: 1, supportsAudioInput: this.supportsAudioInput(state.providerId, state.modelId), extraReserveTokens: toolReserveTokens, @@ -3049,7 +3224,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { preserveEmptyInterleavedReasoning: interleavedReasoning.preserveEmptyReasoningContent === true } - ) + }) + let resumeContext = resumeContextBuild.messages if (budgetToolCall?.id && budgetToolCall.name && useContextBudget) { const resumeBudget = this.fitResumeBudgetForToolCall({ resumeContext, @@ -3096,7 +3272,17 @@ export class AgentRuntimePresenter implements IAgentImplementation { tools, baseSystemPrompt, initialBlocks, - interleavedReasoning + interleavedReasoning, + viewContext: { + taskType: 'resume', + policy: resumeContextBuild.policyId, + policyVersion: resumeContextBuild.policyVersion, + selection: buildTapeViewSelection(resumeContextBuild.metadata), + summaryCursorOrderSeq: summaryState.summaryCursorOrderSeq, + supportsVision: this.supportsVision(state.providerId, state.modelId), + supportsAudioInput: this.supportsAudioInput(state.providerId, state.modelId), + traceDebugEnabled: this.configPresenter.getSetting('traceDebugEnabled') === true + } }) try { this.applyProcessResultStatus(sessionId, result, runId) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index c0d60ebcc..f051ce4d5 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -1,22 +1,35 @@ import { SQLitePresenter } from '../sqlitePresenter' import { nanoid } from 'nanoid' +import { createHash } from 'crypto' import type { AgentTapeAnchorResult, AgentTapeAnchorsOptions, AgentTapeSearchOptions, ChatMessageRecord } from '@shared/types/agent-interface' +import type { + DeepChatTapeViewManifest, + DeepChatTapeViewManifestRecord +} from '@shared/types/tape-view-manifest' +import type { + DeepChatTapeReplayEntrySnapshot, + DeepChatTapeReplayExportOptions, + DeepChatTapeReplaySlice, + DeepChatTapeReplayTraceSnapshot +} from '@shared/types/tape-replay' import type { DeepChatMessageStore } from './messageStore' import type { DeepChatTapeEntryRow, DeepChatTapeSearchInput } from '../sqlitePresenter/tables/deepchatTapeEntries' +import type { DeepChatMessageTraceRow } from '../sqlitePresenter/tables/deepchatMessageTraces' import { appendMessageRecordToTape } from './tapeFacts' import { buildEffectiveTapeView, getLastEffectiveTokenUsage, searchEffectiveTapeRows } from './tapeEffectiveView' +import { hashJson, TAPE_VIEW_MANIFEST_EVENT_NAME } from './tapeViewManifest' export type TapeMigrationState = 'none' | 'ready' @@ -57,6 +70,12 @@ export type TapeForkHandle = { forkSessionId: string } +export type TapeViewManifestSourceMaps = { + latestEntryId: number + anchorEntryIds: number[] + entryIdByMessageId: Map +} + function parseJsonObject(raw: string): Record { try { const parsed = JSON.parse(raw) as unknown @@ -168,6 +187,174 @@ function forkSessionId(parentSessionId: string, forkId: string): string { return `${parentSessionId}::fork::${forkId}` } +function hashString(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} + +function collectEntryIds(values: Array): number[] { + return [...new Set(values.filter((value): value is number => typeof value === 'number'))].sort( + (left, right) => left - right + ) +} + +const VIEW_POLICIES = new Set([ + 'legacy_context_v1', + 'legacy_context_shadow', + 'resume_shadow', + 'tool_loop_shadow', + 'context_pressure_recovery_shadow' +]) + +const VIEW_ENTRY_REASONS = new Set([ + 'system_prompt', + 'selected_history', + 'new_user_input', + 'resume_target', + 'tool_loop_message' +]) + +const VIEW_EXCLUDED_REASONS = new Set([ + 'before_summary_cursor', + 'compaction_indicator', + 'pending_not_context_history', + 'out_of_budget', + 'empty_after_formatting', + 'superseded', + 'retracted' +]) + +function isRecordObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || typeof value === 'number' +} + +function isViewEntryRef(value: unknown): value is DeepChatTapeViewManifest['included'][number] { + if (!isRecordObject(value)) { + return false + } + + return ( + isNullableNumber(value.entryId) && + isNullableString(value.messageId) && + isNullableNumber(value.orderSeq) && + (value.role === 'system' || + value.role === 'user' || + value.role === 'assistant' || + value.role === 'tool' || + value.role === null) && + (value.source === 'tape' || value.source === 'synthetic') && + typeof value.reason === 'string' && + VIEW_ENTRY_REASONS.has(value.reason) + ) +} + +function isViewExcludedRef(value: unknown): value is DeepChatTapeViewManifest['excluded'][number] { + if (!isRecordObject(value)) { + return false + } + + return ( + isNullableNumber(value.entryId) && + isNullableString(value.messageId) && + isNullableNumber(value.orderSeq) && + typeof value.reason === 'string' && + VIEW_EXCLUDED_REASONS.has(value.reason) + ) +} + +function hasNumberFields(value: unknown, fields: string[]): value is Record { + if (!isRecordObject(value)) { + return false + } + + return fields.every((field) => typeof value[field] === 'number') +} + +function hasStringFields(value: unknown, fields: string[]): value is Record { + if (!isRecordObject(value)) { + return false + } + + return fields.every((field) => typeof value[field] === 'string') +} + +function isViewManifestMeta(value: unknown): value is DeepChatTapeViewManifest['meta'] { + if (!isRecordObject(value)) { + return false + } + + return ( + typeof value.providerId === 'string' && + typeof value.modelId === 'string' && + typeof value.summaryCursorOrderSeq === 'number' && + typeof value.supportsVision === 'boolean' && + typeof value.supportsAudioInput === 'boolean' && + typeof value.traceDebugEnabled === 'boolean' + ) +} + +function isViewManifest(value: unknown, sessionId: string): value is DeepChatTapeViewManifest { + if (!isRecordObject(value)) { + return false + } + + return ( + value.schemaVersion === 1 && + value.sessionId === sessionId && + typeof value.viewId === 'string' && + typeof value.messageId === 'string' && + typeof value.requestSeq === 'number' && + (value.taskType === 'chat' || value.taskType === 'resume' || value.taskType === 'tool_loop') && + typeof value.policy === 'string' && + VIEW_POLICIES.has(value.policy) && + (typeof value.policyVersion === 'number' || value.policyVersion === null) && + value.contextBuilderVersion === 'legacy-v1' && + typeof value.latestEntryId === 'number' && + Array.isArray(value.anchorEntryIds) && + value.anchorEntryIds.every((entryId) => typeof entryId === 'number') && + Array.isArray(value.included) && + value.included.every(isViewEntryRef) && + Array.isArray(value.excluded) && + value.excluded.every(isViewExcludedRef) && + hasNumberFields(value.tokenBudget, [ + 'contextLength', + 'requestedMaxTokens', + 'effectiveMaxTokens', + 'reserveTokens', + 'toolReserveTokens', + 'estimatedPromptTokens' + ]) && + hasStringFields(value.hashes, ['promptHash', 'toolDefinitionsHash', 'manifestHash']) && + isViewManifestMeta(value.meta) && + typeof value.assembledAt === 'number' + ) +} + +function withReplaySliceHash( + slice: Omit & { + hashes: Omit & { sliceHash: '' } + } +): DeepChatTapeReplaySlice { + return { + ...slice, + hashes: { + ...slice.hashes, + sliceHash: hashJson(slice) + } + } +} + export class DeepChatTapeService { constructor(private readonly sqlitePresenter: SQLitePresenter) {} @@ -236,7 +423,12 @@ export class DeepChatTapeService { } appendMessageRecord(record: ChatMessageRecord): number { - return appendMessageRecordToTape(this.table, record, 'live') + const table = this.table + if (!table) { + throw new Error('Tape table is not available.') + } + + return appendMessageRecordToTape(table, record, 'live') } getMessageRecords(sessionId: string): ChatMessageRecord[] { @@ -298,6 +490,165 @@ export class DeepChatTapeService { : [] } + getViewManifestSourceMaps(sessionId: string): TapeViewManifestSourceMaps { + const table = this.table + if (!table) { + return { + latestEntryId: 0, + anchorEntryIds: [], + entryIdByMessageId: new Map() + } + } + + const rows = table.getBySession(sessionId) + const entryIdByMessageId = new Map() + let latestEntryId = 0 + const anchorEntryIds: number[] = [] + + for (const row of rows) { + latestEntryId = Math.max(latestEntryId, row.entry_id) + if (row.kind === 'anchor') { + anchorEntryIds.push(row.entry_id) + } + if (row.kind === 'message' && row.source_type === 'message' && row.source_id) { + entryIdByMessageId.set(row.source_id, row.entry_id) + } + } + + return { + latestEntryId, + anchorEntryIds, + entryIdByMessageId + } + } + + appendViewManifest(manifest: DeepChatTapeViewManifest): DeepChatTapeEntryRow { + const table = this.table + if (!table) { + throw new Error('Tape table is not available.') + } + + table.ensureBootstrapAnchor(manifest.sessionId) + return table.appendEvent({ + sessionId: manifest.sessionId, + name: TAPE_VIEW_MANIFEST_EVENT_NAME, + source: { + type: 'runtime_event', + id: manifest.messageId, + seq: manifest.requestSeq + }, + provenanceKey: `view:${manifest.sessionId}:${manifest.messageId}:${manifest.requestSeq}:${manifest.hashes.manifestHash}`, + data: { + manifest + }, + meta: { + viewId: manifest.viewId, + requestSeq: manifest.requestSeq, + taskType: manifest.taskType, + policy: manifest.policy, + policyVersion: manifest.policyVersion + }, + createdAt: manifest.assembledAt, + idempotent: true + }) + } + + listViewManifestsByMessage( + sessionId: string, + messageId: string + ): DeepChatTapeViewManifestRecord[] { + const table = this.table + if (!table) { + return [] + } + + return table + .getBySession(sessionId) + .filter( + (row) => + row.kind === 'event' && + row.name === TAPE_VIEW_MANIFEST_EVENT_NAME && + row.source_type === 'runtime_event' && + row.source_id === messageId + ) + .map((row) => this.toViewManifestRecord(row)) + .filter((record): record is DeepChatTapeViewManifestRecord => Boolean(record)) + .sort((left, right) => right.requestSeq - left.requestSeq || right.entryId - left.entryId) + } + + exportReplaySlice( + sessionId: string, + messageId: string, + options: DeepChatTapeReplayExportOptions = {} + ): DeepChatTapeReplaySlice | null { + if (options.requestSeq !== undefined && !isPositiveInteger(options.requestSeq)) { + throw new Error('requestSeq must be a positive integer.') + } + + const table = this.table + if (!table) { + return null + } + + const manifests = this.listViewManifestsByMessage(sessionId, messageId) + const manifestRecord = + options.requestSeq === undefined + ? manifests[0] + : manifests.find((record) => record.requestSeq === options.requestSeq) + if (!manifestRecord) { + return null + } + + const manifest = manifestRecord.manifest + const includedEntryIds = collectEntryIds(manifest.included.map((ref) => ref.entryId)) + const excludedEntryIds = collectEntryIds(manifest.excluded.map((ref) => ref.entryId)) + const anchorEntryIds = collectEntryIds(manifest.anchorEntryIds) + const selectedEntryIds = new Set([ + manifestRecord.entryId, + ...includedEntryIds, + ...excludedEntryIds, + ...anchorEntryIds + ]) + const entries = table + .getBySession(sessionId) + .filter((row) => selectedEntryIds.has(row.entry_id)) + .map((row) => this.toReplayEntrySnapshot(row, options.includeTapePayloads === true)) + + const trace = this.findReplayTrace(sessionId, messageId, manifestRecord.requestSeq) + const createdAt = Date.now() + const sliceBase: Omit & { + hashes: Omit & { sliceHash: '' } + } = { + schemaVersion: 1 as const, + sliceId: `replay_${hashJson({ + sessionId, + messageId, + requestSeq: manifestRecord.requestSeq, + manifestHash: manifest.hashes.manifestHash + }).slice(0, 16)}`, + sessionId, + messageId, + requestSeq: manifestRecord.requestSeq, + mode: trace ? 'trace_bound' : 'manifest_only', + manifestRecord, + trace: trace ? this.toReplayTraceSnapshot(trace, options.includeTracePayload === true) : null, + entries, + refs: { + manifestEntryId: manifestRecord.entryId, + includedEntryIds, + excludedEntryIds, + anchorEntryIds + }, + hashes: { + manifestHash: manifest.hashes.manifestHash, + sliceHash: '' + }, + createdAt + } + + return withReplaySliceHash(sliceBase) + } + handoff( sessionId: string, name: string, @@ -586,4 +937,91 @@ export class DeepChatTapeService { createdAt: row.created_at } } + + private toViewManifestRecord(row: DeepChatTapeEntryRow): DeepChatTapeViewManifestRecord | null { + const payload = parseJsonObject(row.payload_json) + const data = payload.data + const manifest = + data && typeof data === 'object' && !Array.isArray(data) + ? (data as Record).manifest + : undefined + if (!isViewManifest(manifest, row.session_id)) { + return null + } + + return { + sessionId: row.session_id, + messageId: manifest.messageId, + requestSeq: manifest.requestSeq, + entryId: row.entry_id, + createdAt: row.created_at, + manifest + } + } + + private findReplayTrace( + sessionId: string, + messageId: string, + requestSeq: number + ): DeepChatMessageTraceRow | null { + const traceTable = this.sqlitePresenter.deepchatMessageTracesTable + if (!traceTable) { + return null + } + + return ( + traceTable + .listByMessageId(messageId) + .find((row) => row.session_id === sessionId && row.request_seq === requestSeq) ?? null + ) + } + + private toReplayEntrySnapshot( + row: DeepChatTapeEntryRow, + includePayloads: boolean + ): DeepChatTapeReplayEntrySnapshot { + const snapshot: DeepChatTapeReplayEntrySnapshot = { + entryId: row.entry_id, + kind: row.kind, + name: row.name, + sourceType: row.source_type, + sourceId: row.source_id, + sourceSeq: row.source_seq, + provenanceKey: row.provenance_key, + payloadHash: hashString(row.payload_json), + metaHash: hashString(row.meta_json), + createdAt: row.created_at + } + + if (includePayloads) { + snapshot.payload = parseJsonObject(row.payload_json) + snapshot.meta = parseJsonObject(row.meta_json) + } + + return snapshot + } + + private toReplayTraceSnapshot( + row: DeepChatMessageTraceRow, + includePayload: boolean + ): DeepChatTapeReplayTraceSnapshot { + const snapshot: DeepChatTapeReplayTraceSnapshot = { + id: row.id, + requestSeq: row.request_seq, + providerId: row.provider_id, + modelId: row.model_id, + endpoint: row.endpoint, + headersHash: hashString(row.headers_json), + bodyHash: hashString(row.body_json), + truncated: row.truncated === 1, + createdAt: row.created_at + } + + if (includePayload) { + snapshot.headersJson = row.headers_json + snapshot.bodyJson = row.body_json + } + + return snapshot + } } diff --git a/src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts b/src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts new file mode 100644 index 000000000..7da4fe492 --- /dev/null +++ b/src/main/presenter/agentRuntimePresenter/tapeViewAssembler.ts @@ -0,0 +1,85 @@ +import type { ChatMessage } from '@shared/types/core/chat-message' +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import { isContextHistoryRecord, type ContextBuildMetadata } from './contextBuilder' +import { + resolveTapeViewPolicy, + type TapeChatViewPolicyInput, + type TapeResumeViewPolicyInput, + type TapeViewPolicy, + type TapeViewPolicyId, + type TapeViewPolicySelection, + type TapeViewPolicySelectionReason +} from './tapeViewPolicy' + +export const TAPE_VIEW_ASSEMBLER_VERSION = 'tape-view-assembler-v1' as const +export const TAPE_VIEW_HISTORY_SOURCE = 'tape_effective_view' as const + +export interface TapeViewAssemblerResult { + messages: ChatMessage[] + metadata: ContextBuildMetadata + assemblerVersion: typeof TAPE_VIEW_ASSEMBLER_VERSION + historySource: typeof TAPE_VIEW_HISTORY_SOURCE + historyRecords: ChatMessageRecord[] + policyId: TapeViewPolicyId + policyVersion: TapeViewPolicy['version'] + policySelectionReason: TapeViewPolicySelectionReason +} + +export interface TapeChatViewAssemblerInput extends TapeChatViewPolicyInput { + policy?: TapeViewPolicy + requestedPolicyId?: string | null +} + +export interface TapeResumeViewAssemblerInput extends TapeResumeViewPolicyInput { + policy?: TapeViewPolicy + requestedPolicyId?: string | null +} + +export function getTapeContextHistoryRecords(records: ChatMessageRecord[]): ChatMessageRecord[] { + return records.filter(isContextHistoryRecord) +} + +function withAssemblerMetadata( + result: { messages: ChatMessage[]; metadata: ContextBuildMetadata }, + historyRecords: ChatMessageRecord[], + selection: TapeViewPolicySelection +): TapeViewAssemblerResult { + return { + ...result, + assemblerVersion: TAPE_VIEW_ASSEMBLER_VERSION, + historySource: TAPE_VIEW_HISTORY_SOURCE, + historyRecords, + policyId: selection.policy.id, + policyVersion: selection.policy.version, + policySelectionReason: selection.reason + } +} + +function resolveAssemblerPolicy(input: { + policy?: TapeViewPolicy + requestedPolicyId?: string | null +}): TapeViewPolicySelection { + if (input.policy) { + return { + policy: input.policy, + requestedPolicyId: input.requestedPolicyId ?? null, + reason: 'injected' + } + } + + return resolveTapeViewPolicy({ requestedPolicyId: input.requestedPolicyId }) +} + +export function buildTapeChatView(input: TapeChatViewAssemblerInput): TapeViewAssemblerResult { + const selection = resolveAssemblerPolicy(input) + const result = selection.policy.buildChat(input) + + return withAssemblerMetadata(result, input.historyRecords, selection) +} + +export function buildTapeResumeView(input: TapeResumeViewAssemblerInput): TapeViewAssemblerResult { + const selection = resolveAssemblerPolicy(input) + const result = selection.policy.buildResume(input) + + return withAssemblerMetadata(result, input.historyRecords, selection) +} diff --git a/src/main/presenter/agentRuntimePresenter/tapeViewManifest.ts b/src/main/presenter/agentRuntimePresenter/tapeViewManifest.ts new file mode 100644 index 000000000..9e798363c --- /dev/null +++ b/src/main/presenter/agentRuntimePresenter/tapeViewManifest.ts @@ -0,0 +1,267 @@ +import { createHash } from 'crypto' +import type { ChatMessage } from '@shared/types/core/chat-message' +import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import type { + DeepChatTapeViewEntryRef, + DeepChatTapeViewExcludedRef, + DeepChatTapeViewManifest, + DeepChatTapeViewPolicy, + DeepChatTapeViewTaskType, + DeepChatTapeViewTokenBudget +} from '@shared/types/tape-view-manifest' +import { estimateMessagesTokens } from './contextBuilder' +export { isCompactionRecord } from './contextBuilder' + +export const TAPE_VIEW_MANIFEST_EVENT_NAME = 'view/assembled' +export const TAPE_VIEW_CONTEXT_BUILDER_VERSION = 'legacy-v1' as const + +export type TapeViewManifestSourceMaps = { + entryIdByMessageId?: Map +} + +export type TapeViewManifestBuildInput = { + viewId?: string + sessionId: string + messageId: string + requestSeq: number + taskType: DeepChatTapeViewTaskType + policy: DeepChatTapeViewPolicy + policyVersion?: number | null + messages: ChatMessage[] + tools: MCPToolDefinition[] + latestEntryId: number + anchorEntryIds: number[] + included: DeepChatTapeViewEntryRef[] + excluded: DeepChatTapeViewExcludedRef[] + tokenBudget: Omit + providerId: string + modelId: string + summaryCursorOrderSeq: number + supportsVision: boolean + supportsAudioInput: boolean + traceDebugEnabled: boolean + assembledAt?: number +} + +export type TapeViewManifestPolicyInput = { + recoveredFromContextPressure: boolean + isInitialViewRequest: boolean + viewPolicy?: DeepChatTapeViewPolicy + viewPolicyVersion?: number | null +} + +export type TapeViewManifestPolicyResult = { + policy: DeepChatTapeViewPolicy + policyVersion: number | null +} + +export type TapeViewContextSelection = { + includedRecords: Array<{ + record: ChatMessageRecord + reason: DeepChatTapeViewEntryRef['reason'] + }> + excludedRecords: Array<{ + record: ChatMessageRecord + reason: DeepChatTapeViewExcludedRef['reason'] + }> + includesSystemPrompt: boolean + newUserMessageId?: string | null +} + +export function resolveTapeViewManifestPolicy( + input: TapeViewManifestPolicyInput +): TapeViewManifestPolicyResult { + if (input.recoveredFromContextPressure) { + return { + policy: 'context_pressure_recovery_shadow', + policyVersion: null + } + } + + if (input.isInitialViewRequest && input.viewPolicy) { + return { + policy: input.viewPolicy, + policyVersion: input.viewPolicyVersion ?? null + } + } + + return { + policy: 'tool_loop_shadow', + policyVersion: null + } +} + +function normalizeForStableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeForStableJson) + } + + if (!value || typeof value !== 'object') { + return value + } + + const record = value as Record + return Object.keys(record) + .sort() + .reduce>((result, key) => { + const nested = record[key] + if (nested !== undefined) { + result[key] = normalizeForStableJson(nested) + } + return result + }, {}) +} + +export function stableJsonStringify(value: unknown): string { + return JSON.stringify(normalizeForStableJson(value)) +} + +export function hashJson(value: unknown): string { + return createHash('sha256').update(stableJsonStringify(value)).digest('hex') +} + +function buildViewId(input: TapeViewManifestBuildInput, assembledAt: number): string { + return `view_${hashJson({ + sessionId: input.sessionId, + messageId: input.messageId, + requestSeq: input.requestSeq, + policy: input.policy, + assembledAt + }).slice(0, 16)}` +} + +function attachManifestHash( + manifest: Omit & { + hashes: Omit & { manifestHash: '' } + } +): DeepChatTapeViewManifest { + const manifestForHash = { + ...manifest, + hashes: { + ...manifest.hashes, + manifestHash: '' + } + } + return { + ...manifest, + hashes: { + ...manifest.hashes, + manifestHash: hashJson(manifestForHash) + } + } +} + +export function createTapeViewManifest( + input: TapeViewManifestBuildInput +): DeepChatTapeViewManifest { + const assembledAt = input.assembledAt ?? Date.now() + const viewId = input.viewId ?? buildViewId(input, assembledAt) + const manifest: Omit & { + hashes: Omit & { manifestHash: '' } + } = { + schemaVersion: 1 as const, + viewId, + sessionId: input.sessionId, + messageId: input.messageId, + requestSeq: input.requestSeq, + taskType: input.taskType, + policy: input.policy, + policyVersion: input.policyVersion ?? null, + contextBuilderVersion: TAPE_VIEW_CONTEXT_BUILDER_VERSION, + latestEntryId: input.latestEntryId, + anchorEntryIds: [...input.anchorEntryIds], + included: input.included, + excluded: input.excluded, + tokenBudget: { + ...input.tokenBudget, + estimatedPromptTokens: estimateMessagesTokens(input.messages) + }, + hashes: { + promptHash: hashJson(input.messages), + toolDefinitionsHash: hashJson(input.tools), + manifestHash: '' + }, + meta: { + providerId: input.providerId, + modelId: input.modelId, + summaryCursorOrderSeq: input.summaryCursorOrderSeq, + supportsVision: input.supportsVision, + supportsAudioInput: input.supportsAudioInput, + traceDebugEnabled: input.traceDebugEnabled + }, + assembledAt + } + + return attachManifestHash(manifest) +} + +export function buildIncludedRefs( + selection: TapeViewContextSelection, + sourceMaps: TapeViewManifestSourceMaps = {} +): DeepChatTapeViewEntryRef[] { + const refs: DeepChatTapeViewEntryRef[] = [] + + if (selection.includesSystemPrompt) { + refs.push({ + entryId: null, + messageId: null, + orderSeq: null, + role: 'system', + source: 'synthetic', + reason: 'system_prompt' + }) + } + + for (const item of selection.includedRecords) { + refs.push({ + entryId: sourceMaps.entryIdByMessageId?.get(item.record.id) ?? null, + messageId: item.record.id, + orderSeq: item.record.orderSeq, + role: item.record.role, + source: sourceMaps.entryIdByMessageId?.has(item.record.id) ? 'tape' : 'synthetic', + reason: item.reason + }) + } + + if (selection.newUserMessageId) { + refs.push({ + entryId: sourceMaps.entryIdByMessageId?.get(selection.newUserMessageId) ?? null, + messageId: selection.newUserMessageId, + orderSeq: null, + role: 'user', + source: sourceMaps.entryIdByMessageId?.has(selection.newUserMessageId) ? 'tape' : 'synthetic', + reason: 'new_user_input' + }) + } + + return refs +} + +export function buildExcludedRefs( + selection: TapeViewContextSelection, + sourceMaps: TapeViewManifestSourceMaps = {} +): DeepChatTapeViewExcludedRef[] { + return selection.excludedRecords.map((item) => ({ + entryId: sourceMaps.entryIdByMessageId?.get(item.record.id) ?? null, + messageId: item.record.id, + orderSeq: item.record.orderSeq, + reason: item.reason + })) +} + +export function buildSyntheticRequestRefs(messages: ChatMessage[]): DeepChatTapeViewEntryRef[] { + return messages.map((message) => ({ + entryId: null, + messageId: null, + orderSeq: null, + role: message.role, + source: 'synthetic', + reason: + message.role === 'system' + ? 'system_prompt' + : message.role === 'tool' + ? 'tool_loop_message' + : 'selected_history' + })) +} diff --git a/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts b/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts new file mode 100644 index 000000000..038d73d27 --- /dev/null +++ b/src/main/presenter/agentRuntimePresenter/tapeViewPolicy.ts @@ -0,0 +1,148 @@ +import type { ChatMessageRecord, SendMessageInput } from '@shared/types/agent-interface' +import type { DeepChatMessageStore } from './messageStore' +import { + buildContextWithMetadata, + buildResumeContextWithMetadata, + type ContextBuildOptions, + type ContextBuildResult +} from './contextBuilder' + +export const LEGACY_TAPE_VIEW_POLICY_ID = 'legacy_context_v1' as const +export const LEGACY_TAPE_VIEW_POLICY_VERSION = 1 as const +export const DEFAULT_TAPE_VIEW_POLICY_ID = LEGACY_TAPE_VIEW_POLICY_ID + +export type TapeViewPolicyId = typeof LEGACY_TAPE_VIEW_POLICY_ID +export type TapeViewPolicySelectionReason = + | 'default' + | 'requested' + | 'fallback_default' + | 'injected' + +export interface TapeChatViewPolicyInput { + sessionId: string + newUserContent: string | SendMessageInput + systemPrompt: string + contextLength: number + reserveTokens: number + messageStore: DeepChatMessageStore + supportsVision: boolean + historyRecords: ChatMessageRecord[] + options?: Omit +} + +export interface TapeResumeViewPolicyInput { + sessionId: string + assistantMessageId: string + systemPrompt: string + contextLength: number + reserveTokens: number + messageStore: DeepChatMessageStore + supportsVision: boolean + historyRecords: ChatMessageRecord[] + options?: Omit +} + +export interface TapeViewPolicy { + id: TapeViewPolicyId + version: typeof LEGACY_TAPE_VIEW_POLICY_VERSION + buildChat(input: TapeChatViewPolicyInput): ContextBuildResult + buildResume(input: TapeResumeViewPolicyInput): ContextBuildResult +} + +export interface TapeViewPolicySelectionInput { + requestedPolicyId?: string | null +} + +export interface TapeViewPolicySelection { + policy: TapeViewPolicy + requestedPolicyId: string | null + reason: TapeViewPolicySelectionReason +} + +export const legacyTapeViewPolicy: TapeViewPolicy = { + id: LEGACY_TAPE_VIEW_POLICY_ID, + version: LEGACY_TAPE_VIEW_POLICY_VERSION, + buildChat(input) { + return buildContextWithMetadata( + input.sessionId, + input.newUserContent, + input.systemPrompt, + input.contextLength, + input.reserveTokens, + input.messageStore, + input.supportsVision, + { + ...input.options, + historyRecords: input.historyRecords + } + ) + }, + buildResume(input) { + return buildResumeContextWithMetadata( + input.sessionId, + input.assistantMessageId, + input.systemPrompt, + input.contextLength, + input.reserveTokens, + input.messageStore, + input.supportsVision, + { + ...input.options, + historyRecords: input.historyRecords + } + ) + } +} + +const BUILTIN_TAPE_VIEW_POLICIES: Record = { + [LEGACY_TAPE_VIEW_POLICY_ID]: legacyTapeViewPolicy +} + +export function listTapeViewPolicies(): TapeViewPolicy[] { + return Object.values(BUILTIN_TAPE_VIEW_POLICIES) +} + +export function getTapeViewPolicy(policyId: string | null | undefined): TapeViewPolicy | null { + if (!policyId) { + return null + } + + const normalizedPolicyId = policyId.trim() + if (!normalizedPolicyId) { + return null + } + + return BUILTIN_TAPE_VIEW_POLICIES[normalizedPolicyId as TapeViewPolicyId] ?? null +} + +export function resolveTapeViewPolicy( + input: TapeViewPolicySelectionInput = {} +): TapeViewPolicySelection { + const requestedPolicyId = + typeof input.requestedPolicyId === 'string' && input.requestedPolicyId.trim() + ? input.requestedPolicyId.trim() + : null + + if (requestedPolicyId) { + const requestedPolicy = getTapeViewPolicy(requestedPolicyId) + if (requestedPolicy) { + return { + policy: requestedPolicy, + requestedPolicyId, + reason: 'requested' + } + } + + return { + policy: BUILTIN_TAPE_VIEW_POLICIES[DEFAULT_TAPE_VIEW_POLICY_ID], + requestedPolicyId, + reason: 'fallback_default' + } + } + + return { + policy: BUILTIN_TAPE_VIEW_POLICIES[DEFAULT_TAPE_VIEW_POLICY_ID], + requestedPolicyId: null, + reason: 'default' + } +} diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 8d6ad3a99..25f8a5c9e 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -39,6 +39,11 @@ import type { } from '@shared/types/agent-interface' import type { Message } from '@shared/chat' import type { SearchResult } from '@shared/types/core/search' +import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' +import type { + DeepChatTapeReplayExportOptions, + DeepChatTapeReplaySlice +} from '@shared/types/tape-replay' import type { AcpConfigState, IConfigPresenter, @@ -1427,6 +1432,61 @@ export class AgentSessionPresenter { return await agent.handoffTape(sessionId, name, state) } + async listMessageViewManifests(messageId: string): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return [] + + const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId) + if (!message) return [] + + const session = this.sessionManager.get(message.session_id) + if (!session) return [] + + try { + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.listMessageViewManifests) return [] + + return await agent.listMessageViewManifests(message.session_id, normalizedMessageId) + } catch (error) { + logger.warn('[AgentSessionPresenter] Failed to list message view manifests', { + messageId: normalizedMessageId, + error + }) + return [] + } + } + + async exportMessageTapeReplaySlice( + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise { + const normalizedMessageId = messageId?.trim() + if (!normalizedMessageId) return null + + const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId) + if (!message) return null + + const session = this.sessionManager.get(message.session_id) + if (!session) return null + + try { + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.exportMessageTapeReplaySlice) return null + + return await agent.exportMessageTapeReplaySlice( + message.session_id, + normalizedMessageId, + options + ) + } catch (error) { + logger.warn('[AgentSessionPresenter] Failed to export tape replay slice', { + messageId: normalizedMessageId, + error + }) + return null + } + } + async mergeSubagentTape( parentSessionId: string, childSessionId: string, diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 72ee38504..35f0ab6c9 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -183,6 +183,7 @@ import { sessionsDeactivateRoute, sessionsEditUserMessageRoute, sessionsEnsureAcpDraftRoute, + sessionsExportMessageTapeReplaySliceRoute, sessionsExportRoute, sessionsForkRoute, sessionsGetAcpSessionCommandsRoute, @@ -2228,7 +2229,19 @@ export async function dispatchDeepchatRoute( case sessionsListMessageTracesRoute.name: { const input = sessionsListMessageTracesRoute.input.parse(rawInput) const traces = await runtime.agentSessionPresenter.listMessageTraces(input.messageId) - return sessionsListMessageTracesRoute.output.parse({ traces }) + const manifests = await runtime.agentSessionPresenter.listMessageViewManifests( + input.messageId + ) + return sessionsListMessageTracesRoute.output.parse({ traces, manifests }) + } + + case sessionsExportMessageTapeReplaySliceRoute.name: { + const input = sessionsExportMessageTapeReplaySliceRoute.input.parse(rawInput) + const slice = await runtime.agentSessionPresenter.exportMessageTapeReplaySlice( + input.messageId, + input.options + ) + return sessionsExportMessageTapeReplaySliceRoute.output.parse({ slice }) } case sessionsTranslateTextRoute.name: { diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index fecc7e983..9102dfa8c 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -22,6 +22,7 @@ import { sessionsDeactivateRoute, sessionsEditUserMessageRoute, sessionsEnsureAcpDraftRoute, + sessionsExportMessageTapeReplaySliceRoute, sessionsExportRoute, sessionsForkRoute, sessionsGetAcpSessionCommandsRoute, @@ -64,6 +65,10 @@ import { sessionsUpdateQueuedInputRoute } from '@shared/contracts/routes' import type { CreateSessionInput, SendMessageInput } from '@shared/types/agent-interface' +import type { + DeepChatTapeReplayExportOptions, + DeepChatTapeReplaySlice +} from '@shared/types/tape-replay' import { getDeepchatBridge } from './core' export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge()) { @@ -238,6 +243,32 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() return result.traces } + async function listMessageTraceDiagnostics(messageId: string) { + const result = await bridge.invoke(sessionsListMessageTracesRoute.name, { messageId }) + const manifests = Array.isArray(result.manifests) ? result.manifests : [] + return { + traces: result.traces, + manifests + } + } + + async function listMessageViewManifests(messageId: string) { + const result = await bridge.invoke(sessionsListMessageTracesRoute.name, { messageId }) + const manifests = Array.isArray(result.manifests) ? result.manifests : [] + return manifests + } + + async function exportMessageTapeReplaySlice( + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise { + const result = await bridge.invoke(sessionsExportMessageTapeReplaySliceRoute.name, { + messageId, + options + }) + return result.slice + } + async function translateText(text: string, locale?: string, agentId?: string) { const result = await bridge.invoke(sessionsTranslateTextRoute.name, { text, @@ -526,6 +557,9 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() searchHistory, getSearchResults, listMessageTraces, + listMessageTraceDiagnostics, + listMessageViewManifests, + exportMessageTapeReplaySlice, translateText, getAgents, getUsageDashboard, diff --git a/src/renderer/settings/components/ProviderConfigImportDialog.vue b/src/renderer/settings/components/ProviderConfigImportDialog.vue index 76274d421..133f8d674 100644 --- a/src/renderer/settings/components/ProviderConfigImportDialog.vue +++ b/src/renderer/settings/components/ProviderConfigImportDialog.vue @@ -638,7 +638,7 @@ const runScan = async () => { applyError.value = '' selectedProviderApiTypes.value = {} try { - const result = await providerClient.scanProviderImports() + const result = (await providerClient.scanProviderImports()) as ProviderImportScanResult scanResult.value = result selectedSources.value = new Set( result.sources diff --git a/src/renderer/src/components/mcp-config/AgentMcpSelector.vue b/src/renderer/src/components/mcp-config/AgentMcpSelector.vue index 13e115d99..3adc5e629 100644 --- a/src/renderer/src/components/mcp-config/AgentMcpSelector.vue +++ b/src/renderer/src/components/mcp-config/AgentMcpSelector.vue @@ -36,10 +36,10 @@ const isPluginOwnedServerConfig = (config: AgentMcpServerConfig): boolean => const load = async () => { loading.value = true try { - const [servers, currentSelections] = await Promise.all([ + const [servers, currentSelections] = (await Promise.all([ configClient.getMcpServers(), configClient.getAcpSharedMcpSelections() - ]) + ])) as [Record, string[]] availableServers.value = Object.entries(servers ?? {}) .filter(([, config]) => !isPluginOwnedServerConfig(config)) diff --git a/src/renderer/src/components/trace/TraceDialog.vue b/src/renderer/src/components/trace/TraceDialog.vue index 18481ab18..c9eb52ffb 100644 --- a/src/renderer/src/components/trace/TraceDialog.vue +++ b/src/renderer/src/components/trace/TraceDialog.vue @@ -16,21 +16,21 @@

{{ t('traceDialog.errorDesc') }}

-
-
+
+
-
+
{{ t('traceDialog.endpoint') }}:
{{ selectedTrace.endpoint }} @@ -39,39 +39,195 @@
{{ t('traceDialog.provider') }}: - {{ selectedTrace.providerId }} + {{ diagnosticProviderId || '-' }}
{{ t('traceDialog.model') }}: - {{ selectedTrace.modelId }} + {{ diagnosticModelId || '-' }}
-
-
- {{ t('traceDialog.body') }} -
-
-
-
-
{{ formattedJson }}
+ + +
+
+
+
{{ formattedJson }}
+
-
-
+
+ +

{{ t('traceDialog.requestUnavailable') }}

+

+ {{ t('traceDialog.requestUnavailableDesc') }} +

+
+ + + +
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+ +

{{ t('traceDialog.manifestUnavailable') }}

+

+ {{ t('traceDialog.manifestUnavailableDesc') }} +

+
+
+ + +
+
+

{{ t('traceDialog.includedEntries') }}

+
+ + + + + + + + + + + + + + + + + + + + + +
{{ t('traceDialog.entryId') }}{{ t('traceDialog.messageId') }}{{ t('traceDialog.orderSeq') }}{{ t('traceDialog.role') }}{{ t('traceDialog.source') }}{{ t('traceDialog.reason') }}
{{ formatNullable(entry.entryId) }} + {{ formatNullable(entry.messageId) }} + {{ formatNullable(entry.orderSeq) }}{{ formatNullable(entry.role) }}{{ entry.source }}{{ entry.reason }}
+
+
+ +
+

{{ t('traceDialog.excludedEntries') }}

+
+ + + + + + + + + + + + + + + + + +
{{ t('traceDialog.entryId') }}{{ t('traceDialog.messageId') }}{{ t('traceDialog.orderSeq') }}{{ t('traceDialog.reason') }}
{{ formatNullable(entry.entryId) }} + {{ formatNullable(entry.messageId) }} + {{ formatNullable(entry.orderSeq) }}{{ entry.reason }}
+
+
+
+
+ +

{{ t('traceDialog.manifestUnavailable') }}

+

+ {{ t('traceDialog.manifestUnavailableDesc') }} +

+
+
+ + +
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+ +

{{ t('traceDialog.manifestUnavailable') }}

+

+ {{ t('traceDialog.manifestUnavailableDesc') }} +

+
+
+ +
+ +
+ +

{{ t('traceDialog.empty') }}

+

{{ t('traceDialog.emptyDesc') }}

@@ -91,6 +247,7 @@ import { DialogFooter } from '@shadcn/components/ui/dialog' import { Button } from '@shadcn/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/components/ui/tabs' import { Spinner } from '@shadcn/components/ui/spinner' import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' @@ -99,6 +256,9 @@ import { createSessionClient } from '@api/SessionClient' import { useMonaco } from 'stream-monaco' import { useUiSettingsStore } from '@/stores/uiSettingsStore' import type { MessageTraceRecord } from '@shared/types/agent-interface' +import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' + +type DiagnosticTab = 'request' | 'view' | 'entries' | 'budget' const { t } = useI18n() const deviceClient = createDeviceClient() @@ -140,23 +300,66 @@ const error = ref(false) const copySuccess = ref(false) const requestId = ref(0) const traceList = ref([]) -const selectedTraceId = ref(null) +const manifestList = ref([]) +const selectedRequestSeq = ref(null) +const activeTab = ref('request') + +const diagnosticTabs: Array<{ id: DiagnosticTab; labelKey: string }> = [ + { id: 'request', labelKey: 'traceDialog.tabs.request' }, + { id: 'view', labelKey: 'traceDialog.tabs.view' }, + { id: 'entries', labelKey: 'traceDialog.tabs.entries' }, + { id: 'budget', labelKey: 'traceDialog.tabs.budget' } +] + +const requestOptions = computed(() => { + const seqs = new Set() + for (const trace of traceList.value) { + seqs.add(trace.requestSeq) + } + for (const manifest of manifestList.value) { + seqs.add(manifest.requestSeq) + } + return [...seqs] + .sort((left, right) => right - left) + .map((requestSeq) => ({ + requestSeq + })) +}) + +const hasDiagnostics = computed(() => traceList.value.length > 0 || manifestList.value.length > 0) const selectedTrace = computed(() => { if (!traceList.value.length) { return null } - if (selectedTraceId.value) { - const matched = traceList.value.find((item) => item.id === selectedTraceId.value) - if (matched) { - return matched - } + if (selectedRequestSeq.value !== null) { + return traceList.value.find((item) => item.requestSeq === selectedRequestSeq.value) ?? null } return traceList.value[0] ?? null }) +const selectedManifest = computed(() => { + if (!manifestList.value.length) { + return null + } + + if (selectedRequestSeq.value !== null) { + return manifestList.value.find((item) => item.requestSeq === selectedRequestSeq.value) ?? null + } + + return manifestList.value[0] ?? null +}) + +const diagnosticProviderId = computed( + () => selectedTrace.value?.providerId ?? selectedManifest.value?.manifest.meta.providerId ?? '' +) + +const diagnosticModelId = computed( + () => selectedTrace.value?.modelId ?? selectedManifest.value?.manifest.meta.modelId ?? '' +) + const parsedHeaders = computed(() => { if (!selectedTrace.value) return {} try { @@ -187,6 +390,65 @@ const formattedJson = computed(() => { return JSON.stringify(fullData, null, 2) }) +const activeTabLabel = computed(() => { + const tab = diagnosticTabs.find((item) => item.id === activeTab.value) + return tab ? t(tab.labelKey) : t('traceDialog.body') +}) + +const activeJson = computed(() => { + if (activeTab.value === 'request') { + return formattedJson.value + } + if (!selectedManifest.value) { + return '' + } + if (activeTab.value === 'view') { + return JSON.stringify(selectedManifest.value.manifest, null, 2) + } + if (activeTab.value === 'entries') { + return JSON.stringify( + { + included: selectedManifest.value.manifest.included, + excluded: selectedManifest.value.manifest.excluded + }, + null, + 2 + ) + } + return JSON.stringify(selectedManifest.value.manifest.tokenBudget, null, 2) +}) + +const manifestOverview = computed(() => { + const manifest = selectedManifest.value?.manifest + if (!manifest) return [] + return [ + { label: t('traceDialog.viewId'), value: manifest.viewId }, + { label: t('traceDialog.policy'), value: manifest.policy }, + ...(typeof manifest.policyVersion === 'number' + ? [{ label: t('traceDialog.policyVersion'), value: String(manifest.policyVersion) }] + : []), + { label: t('traceDialog.taskType'), value: manifest.taskType }, + { label: t('traceDialog.requestSeq'), value: String(manifest.requestSeq) }, + { label: t('traceDialog.latestEntryId'), value: String(manifest.latestEntryId) }, + { label: t('traceDialog.promptHash'), value: manifest.hashes.promptHash }, + { label: t('traceDialog.toolDefinitionsHash'), value: manifest.hashes.toolDefinitionsHash }, + { label: t('traceDialog.manifestHash'), value: manifest.hashes.manifestHash } + ] +}) + +const tokenBudgetItems = computed(() => { + const budget = selectedManifest.value?.manifest.tokenBudget + if (!budget) return [] + return [ + { label: t('traceDialog.contextLength'), value: budget.contextLength }, + { label: t('traceDialog.requestedMaxTokens'), value: budget.requestedMaxTokens }, + { label: t('traceDialog.effectiveMaxTokens'), value: budget.effectiveMaxTokens }, + { label: t('traceDialog.reserveTokens'), value: budget.reserveTokens }, + { label: t('traceDialog.toolReserveTokens'), value: budget.toolReserveTokens }, + { label: t('traceDialog.estimatedPromptTokens'), value: budget.estimatedPromptTokens } + ] +}) + watch( () => props.messageId, async (newMessageId) => { @@ -215,10 +477,17 @@ const applyFontFamily = (fontFamily: string) => { } } +watch(activeTab, (tab) => { + if (tab !== 'request') { + cleanupEditor() + editorInitialized.value = false + } +}) + watch( - [isOpen, selectedTrace, formattedJson, jsonEditor], - async ([open, trace, json, editorEl]) => { - if (open && trace && json && editorEl) { + [isOpen, activeTab, selectedTrace, formattedJson, jsonEditor], + async ([open, tab, trace, json, editorEl]) => { + if (open && tab === 'request' && trace && json && editorEl) { await nextTick() await nextTick() const hasEditor = editorEl.querySelector('.monaco-editor') @@ -239,7 +508,13 @@ watch( ) onMounted(async () => { - if (isOpen.value && selectedTrace.value && formattedJson.value && jsonEditor.value) { + if ( + isOpen.value && + activeTab.value === 'request' && + selectedTrace.value && + formattedJson.value && + jsonEditor.value + ) { await nextTick() await nextTick() if (!jsonEditor.value.querySelector('.monaco-editor') && !editorInitialized.value) { @@ -273,21 +548,21 @@ const loadTraces = async (messageId: string) => { loading.value = true error.value = false traceList.value = [] - selectedTraceId.value = null + manifestList.value = [] + selectedRequestSeq.value = null + activeTab.value = 'request' try { - const result = await sessionClient.listMessageTraces(messageId) + const { traces, manifests } = await sessionClient.listMessageTraceDiagnostics(messageId) if (currentRequestId !== requestId.value) { return } - if (!Array.isArray(result) || result.length === 0) { - error.value = true - return - } - - traceList.value = result - selectedTraceId.value = result[0].id + traceList.value = Array.isArray(traces) ? traces : [] + manifestList.value = Array.isArray(manifests) ? manifests : [] + selectedRequestSeq.value = + traceList.value[0]?.requestSeq ?? manifestList.value[0]?.requestSeq ?? null + activeTab.value = traceList.value.length > 0 ? 'request' : 'view' } catch (err) { if (currentRequestId === requestId.value) { console.error('Failed to load message traces:', err) @@ -301,9 +576,9 @@ const loadTraces = async (messageId: string) => { } const copyJson = async () => { - if (!formattedJson.value) return + if (!activeJson.value) return try { - deviceClient.copyText(formattedJson.value) + deviceClient.copyText(activeJson.value) copySuccess.value = true setTimeout(() => { copySuccess.value = false @@ -318,11 +593,20 @@ const resetState = () => { error.value = false copySuccess.value = false traceList.value = [] - selectedTraceId.value = null + manifestList.value = [] + selectedRequestSeq.value = null + activeTab.value = 'request' cleanupEditor() editorInitialized.value = false } +const formatNullable = (value: string | number | null): string => { + if (value === null) { + return '-' + } + return String(value) +} + const close = () => { isOpen.value = false resetState() diff --git a/src/renderer/src/i18n/da-DK/traceDialog.json b/src/renderer/src/i18n/da-DK/traceDialog.json index 7a744a334..39790c8d2 100644 --- a/src/renderer/src/i18n/da-DK/traceDialog.json +++ b/src/renderer/src/i18n/da-DK/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Kan ikke hente anmodningsforhåndsvisning, prøv igen", "notImplemented": "Ikke understøttet", "notImplementedDesc": "Denne udbyder understøtter endnu ikke forhåndsvisning", - "mayNotMatch": "Bemærk: Denne forhåndsvisning er rekonstrueret ud fra de nuværende samtaleindstillinger og matcher muligvis ikke de faktiske anmodningsparametre helt præcist" + "mayNotMatch": "Bemærk: Denne forhåndsvisning er rekonstrueret ud fra de nuværende samtaleindstillinger og matcher muligvis ikke de faktiske anmodningsparametre helt præcist", + "tabs": { + "request": "Anmodning", + "view": "Visning", + "entries": "Poster", + "budget": "Tokenbudget" + }, + "empty": "Ingen diagnostik", + "emptyDesc": "Der er ingen anmodningssporing eller visningsmanifest for denne besked", + "requestUnavailable": "Ingen anmodningssporing", + "requestUnavailableDesc": "Denne besked har ingen gemt udbyderanmodningssporing", + "manifestUnavailable": "Intet visningsmanifest", + "manifestUnavailableDesc": "Denne besked har intet gemt Tape-visningsmanifest", + "viewId": "Visnings-id", + "policy": "Politik", + "policyVersion": "Politikversion", + "taskType": "Opgavetype", + "requestSeq": "Anmodningsnr.", + "latestEntryId": "Seneste post-id", + "promptHash": "Prompt-hash", + "toolDefinitionsHash": "Værktøjsdefinitionshash", + "manifestHash": "Manifest-hash", + "includedEntries": "Inkluderede poster", + "excludedEntries": "Ekskluderede poster", + "entryId": "Post-id", + "messageId": "Besked-id", + "orderSeq": "Rækkefølge", + "role": "Rolle", + "source": "Kilde", + "reason": "Årsag", + "contextLength": "Kontekstlængde", + "requestedMaxTokens": "Anmodede maks. tokens", + "effectiveMaxTokens": "Effektive maks. tokens", + "reserveTokens": "Reserverede tokens", + "toolReserveTokens": "Værktøjsreserverede tokens", + "estimatedPromptTokens": "Estimerede prompt-tokens" } diff --git a/src/renderer/src/i18n/de-DE/traceDialog.json b/src/renderer/src/i18n/de-DE/traceDialog.json index f863240f2..1740a6d91 100644 --- a/src/renderer/src/i18n/de-DE/traceDialog.json +++ b/src/renderer/src/i18n/de-DE/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Vorschauinformationen konnten nicht abgerufen werden. Bitte erneut versuchen", "notImplemented": "Noch nicht unterstützt", "notImplementedDesc": "Für diesen Anbieter ist die Vorschau noch nicht verfügbar", - "mayNotMatch": "Hinweis: Diese Vorschau wird aus den aktuellen Sitzungseinstellungen rekonstruiert und stimmt möglicherweise nicht vollständig mit den tatsächlich gesendeten Parametern überein" + "mayNotMatch": "Hinweis: Diese Vorschau wird aus den aktuellen Sitzungseinstellungen rekonstruiert und stimmt möglicherweise nicht vollständig mit den tatsächlich gesendeten Parametern überein", + "tabs": { + "request": "Anfrage", + "view": "Ansicht", + "entries": "Einträge", + "budget": "Tokenbudget" + }, + "empty": "Keine Diagnosedaten", + "emptyDesc": "Für diese Nachricht ist kein Anfrage-Trace oder Ansichtsmanifest verfügbar", + "requestUnavailable": "Kein Anfrage-Trace", + "requestUnavailableDesc": "Für diese Nachricht ist kein persistierter Anbieter-Anfrage-Trace vorhanden", + "manifestUnavailable": "Kein Ansichtsmanifest", + "manifestUnavailableDesc": "Für diese Nachricht ist kein persistiertes Tape-Ansichtsmanifest vorhanden", + "viewId": "Ansichts-ID", + "policy": "Richtlinie", + "policyVersion": "Richtlinienversion", + "taskType": "Aufgabentyp", + "requestSeq": "Anfrage-Nr.", + "latestEntryId": "Neueste Eintrags-ID", + "promptHash": "Prompt-Hash", + "toolDefinitionsHash": "Werkzeugdefinitions-Hash", + "manifestHash": "Manifest-Hash", + "includedEntries": "Eingeschlossene Einträge", + "excludedEntries": "Ausgeschlossene Einträge", + "entryId": "Eintrags-ID", + "messageId": "Nachrichten-ID", + "orderSeq": "Reihenfolge", + "role": "Rolle", + "source": "Quelle", + "reason": "Grund", + "contextLength": "Kontextlänge", + "requestedMaxTokens": "Angeforderte maximale Tokens", + "effectiveMaxTokens": "Effektive maximale Tokens", + "reserveTokens": "Reservierte Tokens", + "toolReserveTokens": "Für Werkzeuge reservierte Tokens", + "estimatedPromptTokens": "Geschätzte Prompt-Tokens" } diff --git a/src/renderer/src/i18n/en-US/traceDialog.json b/src/renderer/src/i18n/en-US/traceDialog.json index 7884345ec..0a0539cf6 100644 --- a/src/renderer/src/i18n/en-US/traceDialog.json +++ b/src/renderer/src/i18n/en-US/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Unable to fetch request preview, please try again", "notImplemented": "Not supported", "notImplementedDesc": "This provider has not implemented request preview yet", - "mayNotMatch": "Note: This preview is reconstructed from current conversation settings and may not exactly match the actual request parameters" + "mayNotMatch": "Note: This preview is reconstructed from current conversation settings and may not exactly match the actual request parameters", + "tabs": { + "request": "Request", + "view": "View", + "entries": "Entries", + "budget": "Budget" + }, + "empty": "No diagnostics", + "emptyDesc": "No request trace or view manifest is available for this message", + "requestUnavailable": "No request trace", + "requestUnavailableDesc": "This message has no persisted provider request trace", + "manifestUnavailable": "No view manifest", + "manifestUnavailableDesc": "This message has no persisted Tape view manifest", + "viewId": "View ID", + "policy": "Policy", + "policyVersion": "Policy version", + "taskType": "Task type", + "requestSeq": "Request #", + "latestEntryId": "Latest entry ID", + "promptHash": "Prompt hash", + "toolDefinitionsHash": "Tool definitions hash", + "manifestHash": "Manifest hash", + "includedEntries": "Included entries", + "excludedEntries": "Excluded entries", + "entryId": "Entry ID", + "messageId": "Message ID", + "orderSeq": "Order seq", + "role": "Role", + "source": "Source", + "reason": "Reason", + "contextLength": "Context length", + "requestedMaxTokens": "Requested max tokens", + "effectiveMaxTokens": "Effective max tokens", + "reserveTokens": "Reserve tokens", + "toolReserveTokens": "Tool reserve tokens", + "estimatedPromptTokens": "Estimated prompt tokens" } diff --git a/src/renderer/src/i18n/es-ES/traceDialog.json b/src/renderer/src/i18n/es-ES/traceDialog.json index dc5e8211d..386f59a7c 100644 --- a/src/renderer/src/i18n/es-ES/traceDialog.json +++ b/src/renderer/src/i18n/es-ES/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "No se puede obtener la vista previa de la solicitud. Inténtelo de nuevo.", "notImplemented": "No compatible", "notImplementedDesc": "Este provider aún no ha implementado la vista previa de la solicitud.", - "mayNotMatch": "Nota: Esta vista previa se reconstruye a partir de la configuración de conversación actual y es posible que no coincida exactamente con los parámetros de solicitud reales." + "mayNotMatch": "Nota: Esta vista previa se reconstruye a partir de la configuración de conversación actual y es posible que no coincida exactamente con los parámetros de solicitud reales.", + "tabs": { + "request": "Solicitud", + "view": "Vista", + "entries": "Entradas", + "budget": "Presupuesto" + }, + "empty": "Sin diagnósticos", + "emptyDesc": "No hay traza de solicitud ni manifiesto de vista disponible para este mensaje", + "requestUnavailable": "Sin traza de solicitud", + "requestUnavailableDesc": "Este mensaje no tiene una traza de solicitud del proveedor guardada", + "manifestUnavailable": "Sin manifiesto de vista", + "manifestUnavailableDesc": "Este mensaje no tiene un manifiesto de vista de Tape guardado", + "viewId": "ID de vista", + "policy": "Política", + "policyVersion": "Versión de política", + "taskType": "Tipo de tarea", + "requestSeq": "N.º de solicitud", + "latestEntryId": "ID de la entrada más reciente", + "promptHash": "Hash del prompt", + "toolDefinitionsHash": "Hash de definiciones de herramientas", + "manifestHash": "Hash del manifiesto", + "includedEntries": "Entradas incluidas", + "excludedEntries": "Entradas excluidas", + "entryId": "ID de entrada", + "messageId": "ID de mensaje", + "orderSeq": "Secuencia de orden", + "role": "Rol", + "source": "Origen", + "reason": "Motivo", + "contextLength": "Longitud del contexto", + "requestedMaxTokens": "Tokens máximos solicitados", + "effectiveMaxTokens": "Tokens máximos efectivos", + "reserveTokens": "Tokens reservados", + "toolReserveTokens": "Tokens reservados para herramientas", + "estimatedPromptTokens": "Tokens estimados del prompt" } diff --git a/src/renderer/src/i18n/fa-IR/traceDialog.json b/src/renderer/src/i18n/fa-IR/traceDialog.json index 784705695..1842b674f 100644 --- a/src/renderer/src/i18n/fa-IR/traceDialog.json +++ b/src/renderer/src/i18n/fa-IR/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "این تامین کننده در حال حاضر قابل پیش‌نمایش نیست.", "provider": "تامین‌کننده", "model": "مدل", - "title": "پیش‌نمایش پارامتر درخواست" + "title": "پیش‌نمایش پارامتر درخواست", + "tabs": { + "request": "درخواست", + "view": "نما", + "entries": "ورودی‌ها", + "budget": "بودجه" + }, + "empty": "بدون دادهٔ عیب‌یابی", + "emptyDesc": "برای این پیام هیچ ردگیری درخواست یا مانیفست نما موجود نیست", + "requestUnavailable": "بدون ردگیری درخواست", + "requestUnavailableDesc": "این پیام ردگیری درخواست ارائه‌دهندهٔ ذخیره‌شده ندارد", + "manifestUnavailable": "بدون مانیفست نما", + "manifestUnavailableDesc": "این پیام مانیفست نمای Tape ذخیره‌شده ندارد", + "viewId": "شناسهٔ نما", + "policy": "سیاست", + "policyVersion": "نسخهٔ سیاست", + "taskType": "نوع وظیفه", + "requestSeq": "شمارهٔ درخواست", + "latestEntryId": "شناسهٔ آخرین ورودی", + "promptHash": "هش پرامپت", + "toolDefinitionsHash": "هش تعریف‌های ابزار", + "manifestHash": "هش مانیفست", + "includedEntries": "ورودی‌های شامل‌شده", + "excludedEntries": "ورودی‌های حذف‌شده", + "entryId": "شناسهٔ ورودی", + "messageId": "شناسهٔ پیام", + "orderSeq": "ترتیب", + "role": "نقش", + "source": "منبع", + "reason": "دلیل", + "contextLength": "طول زمینه", + "requestedMaxTokens": "حداکثر توکن‌های درخواستی", + "effectiveMaxTokens": "حداکثر توکن‌های مؤثر", + "reserveTokens": "توکن‌های رزرو", + "toolReserveTokens": "توکن‌های رزرو ابزار", + "estimatedPromptTokens": "توکن‌های تخمینی پرامپت" } diff --git a/src/renderer/src/i18n/fr-FR/traceDialog.json b/src/renderer/src/i18n/fr-FR/traceDialog.json index 4764a5d8a..6a0648b04 100644 --- a/src/renderer/src/i18n/fr-FR/traceDialog.json +++ b/src/renderer/src/i18n/fr-FR/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "Ce fournisseur n'est pas encore disponible en aperçu.", "provider": "Fournisseur", "model": "Modèle", - "title": "Aperçu des paramètres de requête" + "title": "Aperçu des paramètres de requête", + "tabs": { + "request": "Requête", + "view": "Vue", + "entries": "Entrées", + "budget": "Budget de jetons" + }, + "empty": "Aucun diagnostic", + "emptyDesc": "Aucune trace de requête ni aucun manifeste de vue n’est disponible pour ce message", + "requestUnavailable": "Aucune trace de requête", + "requestUnavailableDesc": "Ce message ne possède aucune trace de requête fournisseur persistée", + "manifestUnavailable": "Aucun manifeste de vue", + "manifestUnavailableDesc": "Ce message ne possède aucun manifeste de vue Tape persisté", + "viewId": "ID de vue", + "policy": "Politique", + "policyVersion": "Version de la politique", + "taskType": "Type de tâche", + "requestSeq": "N° de requête", + "latestEntryId": "ID de la dernière entrée", + "promptHash": "Hash du prompt", + "toolDefinitionsHash": "Hash des définitions d’outils", + "manifestHash": "Hash du manifeste", + "includedEntries": "Entrées incluses", + "excludedEntries": "Entrées exclues", + "entryId": "ID d’entrée", + "messageId": "ID du message", + "orderSeq": "Ordre", + "role": "Rôle", + "source": "Origine", + "reason": "Raison", + "contextLength": "Longueur du contexte", + "requestedMaxTokens": "Tokens maximum demandés", + "effectiveMaxTokens": "Tokens maximum effectifs", + "reserveTokens": "Tokens réservés", + "toolReserveTokens": "Tokens réservés aux outils", + "estimatedPromptTokens": "Tokens estimés du prompt" } diff --git a/src/renderer/src/i18n/he-IL/traceDialog.json b/src/renderer/src/i18n/he-IL/traceDialog.json index 703332a23..fe3e56391 100644 --- a/src/renderer/src/i18n/he-IL/traceDialog.json +++ b/src/renderer/src/i18n/he-IL/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "לא ניתן להביא תצוגה מקדימה של הבקשה, אנא נסה שוב", "notImplemented": "לא נתמך", "notImplementedDesc": "ספק זה טרם יישם תצוגה מקדימה של הבקשה", - "mayNotMatch": "הערה: תצוגה מקדימה זו משוחזרת מהגדרות השיחה הנוכחיות ועשויה שלא להתאים במדויק לפרמטרי הבקשה בפועל" + "mayNotMatch": "הערה: תצוגה מקדימה זו משוחזרת מהגדרות השיחה הנוכחיות ועשויה שלא להתאים במדויק לפרמטרי הבקשה בפועל", + "tabs": { + "request": "בקשה", + "view": "תצוגה", + "entries": "רשומות", + "budget": "תקציב" + }, + "empty": "אין אבחון", + "emptyDesc": "אין מעקב בקשה או מניפסט תצוגה זמינים עבור הודעה זו", + "requestUnavailable": "אין מעקב בקשה", + "requestUnavailableDesc": "להודעה זו אין מעקב בקשת ספק שנשמר", + "manifestUnavailable": "אין מניפסט תצוגה", + "manifestUnavailableDesc": "להודעה זו אין מניפסט תצוגת Tape שנשמר", + "viewId": "מזהה תצוגה", + "policy": "מדיניות", + "policyVersion": "גרסת מדיניות", + "taskType": "סוג משימה", + "requestSeq": "מס׳ בקשה", + "latestEntryId": "מזהה הרשומה האחרונה", + "promptHash": "גיבוב הפרומפט", + "toolDefinitionsHash": "גיבוב הגדרות הכלים", + "manifestHash": "גיבוב המניפסט", + "includedEntries": "רשומות כלולות", + "excludedEntries": "רשומות שלא נכללו", + "entryId": "מזהה רשומה", + "messageId": "מזהה הודעה", + "orderSeq": "סדר", + "role": "תפקיד", + "source": "מקור", + "reason": "סיבה", + "contextLength": "אורך הקשר", + "requestedMaxTokens": "מספר טוקנים מרבי שהתבקש", + "effectiveMaxTokens": "מספר טוקנים מרבי בפועל", + "reserveTokens": "טוקנים שמורים", + "toolReserveTokens": "טוקנים שמורים לכלים", + "estimatedPromptTokens": "טוקני פרומפט משוערים" } diff --git a/src/renderer/src/i18n/id-ID/traceDialog.json b/src/renderer/src/i18n/id-ID/traceDialog.json index a65b2b360..486ae7b48 100644 --- a/src/renderer/src/i18n/id-ID/traceDialog.json +++ b/src/renderer/src/i18n/id-ID/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Tidak dapat memperoleh informasi pratinjau permintaan, silakan coba lagi", "notImplemented": "Belum didukung", "notImplementedDesc": "Pemasok ini belum tersedia untuk pratinjau", - "mayNotMatch": "Catatan: Pratinjau ini dibuat ulang berdasarkan pengaturan sesi saat ini dan mungkin tidak sama persis dengan parameter yang sebenarnya dikirim." + "mayNotMatch": "Catatan: Pratinjau ini dibuat ulang berdasarkan pengaturan sesi saat ini dan mungkin tidak sama persis dengan parameter yang sebenarnya dikirim.", + "tabs": { + "request": "Permintaan", + "view": "Tampilan", + "entries": "Entri", + "budget": "Anggaran" + }, + "empty": "Tidak ada diagnostik", + "emptyDesc": "Tidak ada jejak permintaan atau manifes tampilan untuk pesan ini", + "requestUnavailable": "Tidak ada jejak permintaan", + "requestUnavailableDesc": "Pesan ini tidak memiliki jejak permintaan penyedia yang tersimpan", + "manifestUnavailable": "Tidak ada manifes tampilan", + "manifestUnavailableDesc": "Pesan ini tidak memiliki manifes tampilan Tape yang tersimpan", + "viewId": "ID tampilan", + "policy": "Kebijakan", + "policyVersion": "Versi kebijakan", + "taskType": "Jenis tugas", + "requestSeq": "No. permintaan", + "latestEntryId": "ID entri terbaru", + "promptHash": "Hash prompt", + "toolDefinitionsHash": "Hash definisi alat", + "manifestHash": "Hash manifes", + "includedEntries": "Entri yang disertakan", + "excludedEntries": "Entri yang dikecualikan", + "entryId": "ID entri", + "messageId": "ID pesan", + "orderSeq": "Urutan", + "role": "Peran", + "source": "Sumber", + "reason": "Alasan", + "contextLength": "Panjang konteks", + "requestedMaxTokens": "Token maksimum yang diminta", + "effectiveMaxTokens": "Token maksimum efektif", + "reserveTokens": "Token cadangan", + "toolReserveTokens": "Token cadangan alat", + "estimatedPromptTokens": "Estimasi token prompt" } diff --git a/src/renderer/src/i18n/it-IT/traceDialog.json b/src/renderer/src/i18n/it-IT/traceDialog.json index a929296d4..3d02fc696 100644 --- a/src/renderer/src/i18n/it-IT/traceDialog.json +++ b/src/renderer/src/i18n/it-IT/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Impossibile ottenere l'anteprima della richiesta. Riprova", "notImplemented": "Non supportato", "notImplementedDesc": "Questo provider non supporta ancora l'anteprima", - "mayNotMatch": "Nota: questa anteprima è ricostruita dalle impostazioni correnti della sessione e potrebbe non corrispondere esattamente ai parametri inviati" + "mayNotMatch": "Nota: questa anteprima è ricostruita dalle impostazioni correnti della sessione e potrebbe non corrispondere esattamente ai parametri inviati", + "tabs": { + "request": "Richiesta", + "view": "Vista", + "entries": "Voci", + "budget": "Budget token" + }, + "empty": "Nessuna diagnostica", + "emptyDesc": "Per questo messaggio non è disponibile alcuna traccia della richiesta o manifesto della vista", + "requestUnavailable": "Nessuna traccia della richiesta", + "requestUnavailableDesc": "Questo messaggio non ha una traccia persistita della richiesta al provider", + "manifestUnavailable": "Nessun manifesto della vista", + "manifestUnavailableDesc": "Questo messaggio non ha un manifesto della vista Tape persistito", + "viewId": "ID vista", + "policy": "Criterio", + "policyVersion": "Versione criterio", + "taskType": "Tipo di attività", + "requestSeq": "N. richiesta", + "latestEntryId": "ID voce più recente", + "promptHash": "Hash del prompt", + "toolDefinitionsHash": "Hash delle definizioni degli strumenti", + "manifestHash": "Hash del manifesto", + "includedEntries": "Voci incluse", + "excludedEntries": "Voci escluse", + "entryId": "ID voce", + "messageId": "ID messaggio", + "orderSeq": "Ordine", + "role": "Ruolo", + "source": "Origine", + "reason": "Motivo", + "contextLength": "Lunghezza del contesto", + "requestedMaxTokens": "Token massimi richiesti", + "effectiveMaxTokens": "Token massimi effettivi", + "reserveTokens": "Token riservati", + "toolReserveTokens": "Token riservati agli strumenti", + "estimatedPromptTokens": "Token stimati del prompt" } diff --git a/src/renderer/src/i18n/ja-JP/traceDialog.json b/src/renderer/src/i18n/ja-JP/traceDialog.json index 6ba0e4fe1..dc5ecb558 100644 --- a/src/renderer/src/i18n/ja-JP/traceDialog.json +++ b/src/renderer/src/i18n/ja-JP/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "このサプライヤーはまだプレビューできません", "provider": "サプライヤー", "model": "モデル", - "title": "リクエストパラメータプレビュー" + "title": "リクエストパラメータプレビュー", + "tabs": { + "request": "リクエスト", + "view": "ビュー", + "entries": "エントリ", + "budget": "予算" + }, + "empty": "診断情報なし", + "emptyDesc": "このメッセージにはリクエストトレースまたはビューマニフェストがありません", + "requestUnavailable": "リクエストトレースなし", + "requestUnavailableDesc": "このメッセージには保存済みのプロバイダーリクエストトレースがありません", + "manifestUnavailable": "ビューマニフェストなし", + "manifestUnavailableDesc": "このメッセージには保存済みの Tape ビューマニフェストがありません", + "viewId": "ビュー ID", + "policy": "ポリシー", + "policyVersion": "ポリシーバージョン", + "taskType": "タスク種別", + "requestSeq": "リクエスト番号", + "latestEntryId": "最新エントリ ID", + "promptHash": "プロンプトハッシュ", + "toolDefinitionsHash": "ツール定義ハッシュ", + "manifestHash": "マニフェストハッシュ", + "includedEntries": "含まれるエントリ", + "excludedEntries": "除外されたエントリ", + "entryId": "エントリ ID", + "messageId": "メッセージ ID", + "orderSeq": "順序", + "role": "ロール", + "source": "ソース", + "reason": "理由", + "contextLength": "コンテキスト長", + "requestedMaxTokens": "要求された最大トークン数", + "effectiveMaxTokens": "有効な最大トークン数", + "reserveTokens": "予約トークン", + "toolReserveTokens": "ツール予約トークン", + "estimatedPromptTokens": "推定プロンプトトークン数" } diff --git a/src/renderer/src/i18n/ko-KR/traceDialog.json b/src/renderer/src/i18n/ko-KR/traceDialog.json index edc2b9bf0..00b7f0bd2 100644 --- a/src/renderer/src/i18n/ko-KR/traceDialog.json +++ b/src/renderer/src/i18n/ko-KR/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "이 공급업체는 아직 미리 볼 수 없습니다.", "provider": "공급업체", "model": "모델", - "title": "요청 매개변수 미리보기" + "title": "요청 매개변수 미리보기", + "tabs": { + "request": "요청", + "view": "보기", + "entries": "항목", + "budget": "예산" + }, + "empty": "진단 없음", + "emptyDesc": "이 메시지에는 요청 추적 또는 보기 매니페스트가 없습니다", + "requestUnavailable": "요청 추적 없음", + "requestUnavailableDesc": "이 메시지에는 저장된 제공자 요청 추적이 없습니다", + "manifestUnavailable": "보기 매니페스트 없음", + "manifestUnavailableDesc": "이 메시지에는 저장된 Tape 보기 매니페스트가 없습니다", + "viewId": "보기 ID", + "policy": "정책", + "policyVersion": "정책 버전", + "taskType": "작업 유형", + "requestSeq": "요청 번호", + "latestEntryId": "최신 항목 ID", + "promptHash": "프롬프트 해시", + "toolDefinitionsHash": "도구 정의 해시", + "manifestHash": "매니페스트 해시", + "includedEntries": "포함된 항목", + "excludedEntries": "제외된 항목", + "entryId": "항목 ID", + "messageId": "메시지 ID", + "orderSeq": "순서", + "role": "역할", + "source": "소스", + "reason": "이유", + "contextLength": "컨텍스트 길이", + "requestedMaxTokens": "요청된 최대 토큰", + "effectiveMaxTokens": "유효 최대 토큰", + "reserveTokens": "예약 토큰", + "toolReserveTokens": "도구 예약 토큰", + "estimatedPromptTokens": "예상 프롬프트 토큰" } diff --git a/src/renderer/src/i18n/ms-MY/traceDialog.json b/src/renderer/src/i18n/ms-MY/traceDialog.json index 732626fbb..165834146 100644 --- a/src/renderer/src/i18n/ms-MY/traceDialog.json +++ b/src/renderer/src/i18n/ms-MY/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Tidak dapat mendapatkan maklumat pratonton permintaan, sila cuba lagi", "notImplemented": "Belum disokong lagi", "notImplementedDesc": "Pembekal ini belum tersedia untuk pratonton", - "mayNotMatch": "Nota: Pratonton ini dibina semula berdasarkan tetapan sesi semasa dan mungkin tidak sama persis dengan parameter yang sebenarnya dihantar." + "mayNotMatch": "Nota: Pratonton ini dibina semula berdasarkan tetapan sesi semasa dan mungkin tidak sama persis dengan parameter yang sebenarnya dihantar.", + "tabs": { + "request": "Permintaan", + "view": "Paparan", + "entries": "Entri", + "budget": "Bajet" + }, + "empty": "Tiada diagnostik", + "emptyDesc": "Tiada jejak permintaan atau manifes paparan tersedia untuk mesej ini", + "requestUnavailable": "Tiada jejak permintaan", + "requestUnavailableDesc": "Mesej ini tiada jejak permintaan penyedia yang disimpan", + "manifestUnavailable": "Tiada manifes paparan", + "manifestUnavailableDesc": "Mesej ini tiada manifes paparan Tape yang disimpan", + "viewId": "ID paparan", + "policy": "Dasar", + "policyVersion": "Versi dasar", + "taskType": "Jenis tugas", + "requestSeq": "No. permintaan", + "latestEntryId": "ID entri terkini", + "promptHash": "Hash prompt", + "toolDefinitionsHash": "Hash definisi alat", + "manifestHash": "Hash manifes", + "includedEntries": "Entri disertakan", + "excludedEntries": "Entri dikecualikan", + "entryId": "ID entri", + "messageId": "ID mesej", + "orderSeq": "Urutan", + "role": "Peranan", + "source": "Sumber", + "reason": "Sebab", + "contextLength": "Panjang konteks", + "requestedMaxTokens": "Token maksimum diminta", + "effectiveMaxTokens": "Token maksimum berkesan", + "reserveTokens": "Token simpanan", + "toolReserveTokens": "Token simpanan alat", + "estimatedPromptTokens": "Anggaran token prompt" } diff --git a/src/renderer/src/i18n/pl-PL/traceDialog.json b/src/renderer/src/i18n/pl-PL/traceDialog.json index e94c479b0..c7c43668c 100644 --- a/src/renderer/src/i18n/pl-PL/traceDialog.json +++ b/src/renderer/src/i18n/pl-PL/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Nie można pobrać podglądu żądania. Spróbuj ponownie", "notImplemented": "Nieobsługiwane", "notImplementedDesc": "Ten dostawca nie wdrożył jeszcze podglądu żądań", - "mayNotMatch": "Uwaga: ten podgląd jest rekonstruowany na podstawie bieżących ustawień konwersacji i może nie odpowiadać dokładnie rzeczywistym parametrom żądania" + "mayNotMatch": "Uwaga: ten podgląd jest rekonstruowany na podstawie bieżących ustawień konwersacji i może nie odpowiadać dokładnie rzeczywistym parametrom żądania", + "tabs": { + "request": "Żądanie", + "view": "Widok", + "entries": "Wpisy", + "budget": "Budżet" + }, + "empty": "Brak diagnostyki", + "emptyDesc": "Dla tej wiadomości nie ma śladu żądania ani manifestu widoku", + "requestUnavailable": "Brak śladu żądania", + "requestUnavailableDesc": "Ta wiadomość nie ma zapisanego śladu żądania dostawcy", + "manifestUnavailable": "Brak manifestu widoku", + "manifestUnavailableDesc": "Ta wiadomość nie ma zapisanego manifestu widoku Tape", + "viewId": "ID widoku", + "policy": "Zasada", + "policyVersion": "Wersja zasady", + "taskType": "Typ zadania", + "requestSeq": "Nr żądania", + "latestEntryId": "ID najnowszego wpisu", + "promptHash": "Hash promptu", + "toolDefinitionsHash": "Hash definicji narzędzi", + "manifestHash": "Hash manifestu", + "includedEntries": "Uwzględnione wpisy", + "excludedEntries": "Wykluczone wpisy", + "entryId": "ID wpisu", + "messageId": "ID wiadomości", + "orderSeq": "Kolejność", + "role": "Rola", + "source": "Źródło", + "reason": "Powód", + "contextLength": "Długość kontekstu", + "requestedMaxTokens": "Żądane maks. tokeny", + "effectiveMaxTokens": "Efektywne maks. tokeny", + "reserveTokens": "Zarezerwowane tokeny", + "toolReserveTokens": "Tokeny zarezerwowane dla narzędzi", + "estimatedPromptTokens": "Szacowane tokeny promptu" } diff --git a/src/renderer/src/i18n/pt-BR/traceDialog.json b/src/renderer/src/i18n/pt-BR/traceDialog.json index 6e63e4e8c..d6d94b205 100644 --- a/src/renderer/src/i18n/pt-BR/traceDialog.json +++ b/src/renderer/src/i18n/pt-BR/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "Este fornecedor ainda não está disponível para visualização.", "provider": "Fornecedor", "model": "Modelo", - "title": "Prévia dos parâmetros da solicitação" + "title": "Prévia dos parâmetros da solicitação", + "tabs": { + "request": "Requisição", + "view": "Visualização", + "entries": "Entradas", + "budget": "Orçamento" + }, + "empty": "Sem diagnósticos", + "emptyDesc": "Não há rastreamento de requisição nem manifesto de visualização disponível para esta mensagem", + "requestUnavailable": "Sem rastreamento de requisição", + "requestUnavailableDesc": "Esta mensagem não tem rastreamento persistido da requisição ao provedor", + "manifestUnavailable": "Sem manifesto de visualização", + "manifestUnavailableDesc": "Esta mensagem não tem manifesto de visualização do Tape persistido", + "viewId": "ID da visualização", + "policy": "Política", + "policyVersion": "Versão da política", + "taskType": "Tipo de tarefa", + "requestSeq": "Nº da requisição", + "latestEntryId": "ID da entrada mais recente", + "promptHash": "Hash do prompt", + "toolDefinitionsHash": "Hash das definições de ferramentas", + "manifestHash": "Hash do manifesto", + "includedEntries": "Entradas incluídas", + "excludedEntries": "Entradas excluídas", + "entryId": "ID da entrada", + "messageId": "ID da mensagem", + "orderSeq": "Ordem", + "role": "Função", + "source": "Origem", + "reason": "Motivo", + "contextLength": "Tamanho do contexto", + "requestedMaxTokens": "Máximo de tokens solicitado", + "effectiveMaxTokens": "Máximo de tokens efetivo", + "reserveTokens": "Tokens reservados", + "toolReserveTokens": "Tokens reservados para ferramentas", + "estimatedPromptTokens": "Tokens estimados do prompt" } diff --git a/src/renderer/src/i18n/ru-RU/traceDialog.json b/src/renderer/src/i18n/ru-RU/traceDialog.json index f51c4c130..173a7e9dc 100644 --- a/src/renderer/src/i18n/ru-RU/traceDialog.json +++ b/src/renderer/src/i18n/ru-RU/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "Этот поставщик пока недоступен для предварительного просмотра.", "provider": "Поставщик", "title": "Предварительный просмотр параметров запроса", - "model": "Модель" + "model": "Модель", + "tabs": { + "request": "Запрос", + "view": "Представление", + "entries": "Записи", + "budget": "Бюджет" + }, + "empty": "Нет диагностических данных", + "emptyDesc": "Для этого сообщения нет трассировки запроса или манифеста представления", + "requestUnavailable": "Нет трассировки запроса", + "requestUnavailableDesc": "Для этого сообщения нет сохранённой трассировки запроса к провайдеру", + "manifestUnavailable": "Нет манифеста представления", + "manifestUnavailableDesc": "Для этого сообщения нет сохранённого манифеста представления Tape", + "viewId": "ID представления", + "policy": "Политика", + "policyVersion": "Версия политики", + "taskType": "Тип задачи", + "requestSeq": "№ запроса", + "latestEntryId": "ID последней записи", + "promptHash": "Хеш промпта", + "toolDefinitionsHash": "Хеш определений инструментов", + "manifestHash": "Хеш манифеста", + "includedEntries": "Включённые записи", + "excludedEntries": "Исключённые записи", + "entryId": "ID записи", + "messageId": "ID сообщения", + "orderSeq": "Порядок", + "role": "Роль", + "source": "Источник", + "reason": "Причина", + "contextLength": "Длина контекста", + "requestedMaxTokens": "Запрошенный максимум токенов", + "effectiveMaxTokens": "Фактический максимум токенов", + "reserveTokens": "Зарезервированные токены", + "toolReserveTokens": "Токены, зарезервированные для инструментов", + "estimatedPromptTokens": "Оценка токенов промпта" } diff --git a/src/renderer/src/i18n/tr-TR/traceDialog.json b/src/renderer/src/i18n/tr-TR/traceDialog.json index 707795a95..abed5187b 100644 --- a/src/renderer/src/i18n/tr-TR/traceDialog.json +++ b/src/renderer/src/i18n/tr-TR/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "İstek önizlemesi getirilemiyor, lütfen tekrar deneyin", "notImplemented": "Desteklenmiyor", "notImplementedDesc": "Bu sağlayıcı henüz istek önizlemesini uygulamadı", - "mayNotMatch": "Not: Bu önizleme mevcut görüşme ayarlarından yeniden oluşturulmuştur ve gerçek istek parametreleriyle tam olarak eşleşmeyebilir" + "mayNotMatch": "Not: Bu önizleme mevcut görüşme ayarlarından yeniden oluşturulmuştur ve gerçek istek parametreleriyle tam olarak eşleşmeyebilir", + "tabs": { + "request": "İstek", + "view": "Görünüm", + "entries": "Girdiler", + "budget": "Bütçe" + }, + "empty": "Tanılama yok", + "emptyDesc": "Bu ileti için istek izi veya görünüm bildirimi yok", + "requestUnavailable": "İstek izi yok", + "requestUnavailableDesc": "Bu iletinin kalıcı sağlayıcı istek izi yok", + "manifestUnavailable": "Görünüm bildirimi yok", + "manifestUnavailableDesc": "Bu iletinin kalıcı Tape görünüm bildirimi yok", + "viewId": "Görünüm Kimliği", + "policy": "İlke", + "policyVersion": "İlke sürümü", + "taskType": "Görev türü", + "requestSeq": "İstek no.", + "latestEntryId": "En son girdi kimliği", + "promptHash": "Prompt karması", + "toolDefinitionsHash": "Araç tanımları karması", + "manifestHash": "Bildirim karması", + "includedEntries": "Dahil edilen girdiler", + "excludedEntries": "Hariç tutulan girdiler", + "entryId": "Girdi kimliği", + "messageId": "İleti kimliği", + "orderSeq": "Sıra", + "role": "Rol", + "source": "Kaynak", + "reason": "Neden", + "contextLength": "Bağlam uzunluğu", + "requestedMaxTokens": "İstenen en fazla token", + "effectiveMaxTokens": "Geçerli en fazla token", + "reserveTokens": "Ayrılmış tokenlar", + "toolReserveTokens": "Araç için ayrılmış tokenlar", + "estimatedPromptTokens": "Tahmini prompt tokenları" } diff --git a/src/renderer/src/i18n/vi-VN/traceDialog.json b/src/renderer/src/i18n/vi-VN/traceDialog.json index fb81ddce5..49fab3531 100644 --- a/src/renderer/src/i18n/vi-VN/traceDialog.json +++ b/src/renderer/src/i18n/vi-VN/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "Không thể tìm nạp bản xem trước yêu cầu, vui lòng thử lại", "notImplemented": "Không được hỗ trợ", "notImplementedDesc": "Nhà cung cấp này chưa triển khai bản xem trước yêu cầu", - "mayNotMatch": "Lưu ý: Bản xem trước này được xây dựng lại từ cài đặt cuộc trò chuyện hiện tại và có thể không khớp chính xác với các tham số yêu cầu thực tế" + "mayNotMatch": "Lưu ý: Bản xem trước này được xây dựng lại từ cài đặt cuộc trò chuyện hiện tại và có thể không khớp chính xác với các tham số yêu cầu thực tế", + "tabs": { + "request": "Yêu cầu", + "view": "Chế độ xem", + "entries": "Mục", + "budget": "Ngân sách" + }, + "empty": "Không có chẩn đoán", + "emptyDesc": "Không có vết yêu cầu hoặc bản kê chế độ xem cho tin nhắn này", + "requestUnavailable": "Không có vết yêu cầu", + "requestUnavailableDesc": "Tin nhắn này không có vết yêu cầu nhà cung cấp đã lưu", + "manifestUnavailable": "Không có bản kê chế độ xem", + "manifestUnavailableDesc": "Tin nhắn này không có bản kê chế độ xem Tape đã lưu", + "viewId": "ID chế độ xem", + "policy": "Chính sách", + "policyVersion": "Phiên bản chính sách", + "taskType": "Loại tác vụ", + "requestSeq": "Số yêu cầu", + "latestEntryId": "ID mục mới nhất", + "promptHash": "Hash prompt", + "toolDefinitionsHash": "Hash định nghĩa công cụ", + "manifestHash": "Hash bản kê", + "includedEntries": "Mục được bao gồm", + "excludedEntries": "Mục bị loại trừ", + "entryId": "ID mục", + "messageId": "ID tin nhắn", + "orderSeq": "Thứ tự", + "role": "Vai trò", + "source": "Nguồn", + "reason": "Lý do", + "contextLength": "Độ dài ngữ cảnh", + "requestedMaxTokens": "Số token tối đa đã yêu cầu", + "effectiveMaxTokens": "Số token tối đa hiệu dụng", + "reserveTokens": "Token dự trữ", + "toolReserveTokens": "Token dự trữ cho công cụ", + "estimatedPromptTokens": "Token prompt ước tính" } diff --git a/src/renderer/src/i18n/zh-CN/traceDialog.json b/src/renderer/src/i18n/zh-CN/traceDialog.json index b4ec2e0a7..2a3c43ed5 100644 --- a/src/renderer/src/i18n/zh-CN/traceDialog.json +++ b/src/renderer/src/i18n/zh-CN/traceDialog.json @@ -13,5 +13,40 @@ "errorDesc": "无法获取请求预览信息,请重试", "notImplemented": "暂不支持", "notImplementedDesc": "此供应商暂时还无法预览", - "mayNotMatch": "注意:此预览基于当前会话设置重建,可能与实际发送时的参数不完全一致" + "mayNotMatch": "注意:此预览基于当前会话设置重建,可能与实际发送时的参数不完全一致", + "tabs": { + "request": "请求", + "view": "视图", + "entries": "条目", + "budget": "预算" + }, + "empty": "暂无诊断数据", + "emptyDesc": "此消息暂无请求追踪或视图清单", + "requestUnavailable": "暂无请求追踪", + "requestUnavailableDesc": "此消息暂无持久化的供应商请求追踪", + "manifestUnavailable": "暂无视图清单", + "manifestUnavailableDesc": "此消息暂无持久化的 Tape 视图清单", + "viewId": "视图 ID", + "policy": "策略", + "policyVersion": "策略版本", + "taskType": "任务类型", + "requestSeq": "请求序号", + "latestEntryId": "最新条目 ID", + "promptHash": "Prompt Hash", + "toolDefinitionsHash": "工具定义 Hash", + "manifestHash": "清单 Hash", + "includedEntries": "包含条目", + "excludedEntries": "排除条目", + "entryId": "条目 ID", + "messageId": "消息 ID", + "orderSeq": "顺序号", + "role": "角色", + "source": "来源", + "reason": "原因", + "contextLength": "上下文长度", + "requestedMaxTokens": "请求 Max Tokens", + "effectiveMaxTokens": "生效 Max Tokens", + "reserveTokens": "预留 Tokens", + "toolReserveTokens": "工具预留 Tokens", + "estimatedPromptTokens": "估算 Prompt Tokens" } diff --git a/src/renderer/src/i18n/zh-HK/traceDialog.json b/src/renderer/src/i18n/zh-HK/traceDialog.json index 0952eee7c..87ebfaf09 100644 --- a/src/renderer/src/i18n/zh-HK/traceDialog.json +++ b/src/renderer/src/i18n/zh-HK/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "此供應商暫時無法預覽", "provider": "供應商", "title": "請求參數預覽", - "model": "Model" + "model": "Model", + "tabs": { + "request": "要求", + "view": "檢視", + "entries": "項目", + "budget": "預算" + }, + "empty": "沒有診斷資料", + "emptyDesc": "此訊息沒有可用的要求追蹤或檢視清單", + "requestUnavailable": "沒有要求追蹤", + "requestUnavailableDesc": "此訊息沒有已儲存的供應商要求追蹤", + "manifestUnavailable": "沒有檢視清單", + "manifestUnavailableDesc": "此訊息沒有已儲存的 Tape 檢視清單", + "viewId": "檢視 ID", + "policy": "策略", + "policyVersion": "策略版本", + "taskType": "工作類型", + "requestSeq": "要求編號", + "latestEntryId": "最新項目 ID", + "promptHash": "提示詞雜湊", + "toolDefinitionsHash": "工具定義雜湊", + "manifestHash": "清單雜湊", + "includedEntries": "已包含項目", + "excludedEntries": "已排除項目", + "entryId": "項目 ID", + "messageId": "訊息 ID", + "orderSeq": "排序序號", + "role": "角色", + "source": "來源", + "reason": "原因", + "contextLength": "上下文長度", + "requestedMaxTokens": "要求的最大 Tokens", + "effectiveMaxTokens": "實際最大 Tokens", + "reserveTokens": "預留 Tokens", + "toolReserveTokens": "工具預留 Tokens", + "estimatedPromptTokens": "估算提示詞 Tokens" } diff --git a/src/renderer/src/i18n/zh-TW/traceDialog.json b/src/renderer/src/i18n/zh-TW/traceDialog.json index 84460c6fb..6197aa0a4 100644 --- a/src/renderer/src/i18n/zh-TW/traceDialog.json +++ b/src/renderer/src/i18n/zh-TW/traceDialog.json @@ -13,5 +13,40 @@ "notImplementedDesc": "此供應商暫時無法預覽", "provider": "供應商", "title": "請求參數預覽", - "model": "Model" + "model": "Model", + "tabs": { + "request": "請求", + "view": "檢視", + "entries": "項目", + "budget": "預算" + }, + "empty": "沒有診斷資料", + "emptyDesc": "此訊息沒有可用的請求追蹤或檢視清單", + "requestUnavailable": "沒有請求追蹤", + "requestUnavailableDesc": "此訊息沒有已儲存的供應商請求追蹤", + "manifestUnavailable": "沒有檢視清單", + "manifestUnavailableDesc": "此訊息沒有已儲存的 Tape 檢視清單", + "viewId": "檢視 ID", + "policy": "策略", + "policyVersion": "策略版本", + "taskType": "任務類型", + "requestSeq": "請求序號", + "latestEntryId": "最新項目 ID", + "promptHash": "提示詞雜湊", + "toolDefinitionsHash": "工具定義雜湊", + "manifestHash": "清單雜湊", + "includedEntries": "包含的項目", + "excludedEntries": "排除的項目", + "entryId": "項目 ID", + "messageId": "訊息 ID", + "orderSeq": "排序序號", + "role": "角色", + "source": "來源", + "reason": "原因", + "contextLength": "脈絡長度", + "requestedMaxTokens": "請求的最大 Tokens", + "effectiveMaxTokens": "實際最大 Tokens", + "reserveTokens": "保留 Tokens", + "toolReserveTokens": "工具保留 Tokens", + "estimatedPromptTokens": "預估提示詞 Tokens" } diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 6aabdaa60..07eb8ed21 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -293,6 +293,7 @@ import { sessionsDeactivateRoute, sessionsEditUserMessageRoute, sessionsEnsureAcpDraftRoute, + sessionsExportMessageTapeReplaySliceRoute, sessionsExportRoute, sessionsForkRoute, sessionsGetAcpSessionCommandsRoute, @@ -675,6 +676,7 @@ export const DEEPCHAT_ROUTE_CATALOG = { [sessionsSearchHistoryRoute.name]: sessionsSearchHistoryRoute, [sessionsGetSearchResultsRoute.name]: sessionsGetSearchResultsRoute, [sessionsListMessageTracesRoute.name]: sessionsListMessageTracesRoute, + [sessionsExportMessageTapeReplaySliceRoute.name]: sessionsExportMessageTapeReplaySliceRoute, [sessionsTranslateTextRoute.name]: sessionsTranslateTextRoute, [sessionsGetAgentsRoute.name]: sessionsGetAgentsRoute, [sessionsGetUsageDashboardRoute.name]: sessionsGetUsageDashboardRoute, diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index d124433d0..d1bd11759 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -8,6 +8,8 @@ import type { SendMessageInput } from '@shared/types/agent-interface' import type { HistorySearchHit } from '@shared/types/presenters/agent-session.presenter' +import type { DeepChatTapeReplaySlice } from '@shared/types/tape-replay' +import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' import { SessionListItemSchema, SessionPageCursorSchema, @@ -23,10 +25,13 @@ import { SessionWithStateSchema, defineRouteContract } from '../common' +import type { RouteContract } from '../common' import { AcpConfigStateSchema, UsageDashboardDataSchema } from '../domainSchemas' const PendingSessionInputRecordSchema = z.custom() const MessageTraceRecordSchema = z.custom() +const DeepChatTapeViewManifestRecordSchema = z.custom() +const DeepChatTapeReplaySliceSchema = z.custom().nullable() const HistorySearchHitSchema = z.custom() const SearchResultSchema = z.custom() const AgentSchema = z.custom() @@ -321,13 +326,32 @@ export const sessionsGetSearchResultsRoute = defineRouteContract({ }) }) -export const sessionsListMessageTracesRoute = defineRouteContract({ - name: 'sessions.listMessageTraces', +export const sessionsListMessageTracesRoute: RouteContract<'sessions.listMessageTraces'> = + defineRouteContract({ + name: 'sessions.listMessageTraces', + input: z.object({ + messageId: EntityIdSchema + }), + output: z.object({ + traces: z.array(MessageTraceRecordSchema), + manifests: z.array(DeepChatTapeViewManifestRecordSchema) + }) + }) + +export const sessionsExportMessageTapeReplaySliceRoute = defineRouteContract({ + name: 'sessions.exportMessageTapeReplaySlice', input: z.object({ - messageId: EntityIdSchema + messageId: EntityIdSchema, + options: z + .object({ + requestSeq: z.number().int().positive().optional(), + includeTapePayloads: z.boolean().optional(), + includeTracePayload: z.boolean().optional() + }) + .optional() }), output: z.object({ - traces: z.array(MessageTraceRecordSchema) + slice: DeepChatTapeReplaySliceSchema }) }) diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 146245cba..32584589f 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -3,6 +3,8 @@ import type { ImageGenerationOptions } from '../imageGenerationSettings' import type { VideoGenerationOptions } from '../videoGenerationSettings' import type { ToolCallImagePreview } from './core/mcp' import type { AgentPlanDisplayItem } from './agent-plan' +import type { DeepChatTapeViewManifestRecord } from './tape-view-manifest' +import type { DeepChatTapeReplayExportOptions, DeepChatTapeReplaySlice } from './tape-replay' /** * Agent Interface Protocol @@ -209,6 +211,19 @@ export interface IAgentImplementation { state?: Record ): Promise + /** List prompt view manifests associated with a message */ + listMessageViewManifests?( + sessionId: string, + messageId: string + ): Promise + + /** Export a deterministic tape replay slice for a message request */ + exportMessageTapeReplaySlice?( + sessionId: string, + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise + /** Record a completed child session as a merged tape fork */ mergeSubagentTape?( parentSessionId: string, diff --git a/src/shared/types/presenters/agent-session.presenter.d.ts b/src/shared/types/presenters/agent-session.presenter.d.ts index 7aa8abd03..c57dc0119 100644 --- a/src/shared/types/presenters/agent-session.presenter.d.ts +++ b/src/shared/types/presenters/agent-session.presenter.d.ts @@ -27,6 +27,8 @@ import type { AgentTapeAnchorResult, AgentTransferImpact } from '../agent-interface' +import type { DeepChatTapeViewManifestRecord } from '../tape-view-manifest' +import type { DeepChatTapeReplayExportOptions, DeepChatTapeReplaySlice } from '../tape-replay' import type { AcpConfigState } from './llmprovider.presenter' import type { SearchResult } from './thread.presenter' @@ -137,6 +139,11 @@ export interface IAgentSessionPresenter { getLegacyImportStatus(): Promise retryLegacyImport(): Promise listMessageTraces(messageId: string): Promise + listMessageViewManifests(messageId: string): Promise + exportMessageTapeReplaySlice( + messageId: string, + options?: DeepChatTapeReplayExportOptions + ): Promise getMessageTraceCount(messageId: string): Promise getMessageIds(sessionId: string): Promise getMessage(messageId: string): Promise diff --git a/src/shared/types/tape-replay.ts b/src/shared/types/tape-replay.ts new file mode 100644 index 000000000..5117d4acc --- /dev/null +++ b/src/shared/types/tape-replay.ts @@ -0,0 +1,63 @@ +import type { DeepChatTapeViewManifestRecord } from './tape-view-manifest' + +export interface DeepChatTapeReplayExportOptions { + requestSeq?: number + includeTapePayloads?: boolean + includeTracePayload?: boolean +} + +export interface DeepChatTapeReplayTraceSnapshot { + id: string + requestSeq: number + providerId: string + modelId: string + endpoint: string + headersHash: string + bodyHash: string + truncated: boolean + createdAt: number + headersJson?: string + bodyJson?: string +} + +export interface DeepChatTapeReplayEntrySnapshot { + entryId: number + kind: string + name: string | null + sourceType: string | null + sourceId: string | null + sourceSeq: number | null + provenanceKey: string | null + payloadHash: string + metaHash: string + createdAt: number + payload?: Record + meta?: Record +} + +export interface DeepChatTapeReplaySliceRefs { + manifestEntryId: number + includedEntryIds: number[] + excludedEntryIds: number[] + anchorEntryIds: number[] +} + +export interface DeepChatTapeReplaySliceHashes { + manifestHash: string + sliceHash: string +} + +export interface DeepChatTapeReplaySlice { + schemaVersion: 1 + sliceId: string + sessionId: string + messageId: string + requestSeq: number + mode: 'manifest_only' | 'trace_bound' + manifestRecord: DeepChatTapeViewManifestRecord + trace: DeepChatTapeReplayTraceSnapshot | null + entries: DeepChatTapeReplayEntrySnapshot[] + refs: DeepChatTapeReplaySliceRefs + hashes: DeepChatTapeReplaySliceHashes + createdAt: number +} diff --git a/src/shared/types/tape-view-manifest.ts b/src/shared/types/tape-view-manifest.ts new file mode 100644 index 000000000..35d4fdad4 --- /dev/null +++ b/src/shared/types/tape-view-manifest.ts @@ -0,0 +1,97 @@ +export type DeepChatTapeViewTaskType = 'chat' | 'resume' | 'tool_loop' + +export type DeepChatTapeViewPolicy = + | 'legacy_context_v1' + | 'legacy_context_shadow' + | 'resume_shadow' + | 'tool_loop_shadow' + | 'context_pressure_recovery_shadow' + +export type DeepChatTapeViewEntryRole = 'system' | 'user' | 'assistant' | 'tool' | null + +export type DeepChatTapeViewEntrySource = 'tape' | 'synthetic' + +export type DeepChatTapeViewEntryReason = + | 'system_prompt' + | 'selected_history' + | 'new_user_input' + | 'resume_target' + | 'tool_loop_message' + +export type DeepChatTapeViewExcludedReason = + | 'before_summary_cursor' + | 'compaction_indicator' + | 'pending_not_context_history' + | 'out_of_budget' + | 'empty_after_formatting' + | 'superseded' + | 'retracted' + +export interface DeepChatTapeViewEntryRef { + entryId: number | null + messageId: string | null + orderSeq: number | null + role: DeepChatTapeViewEntryRole + source: DeepChatTapeViewEntrySource + reason: DeepChatTapeViewEntryReason +} + +export interface DeepChatTapeViewExcludedRef { + entryId: number | null + messageId: string | null + orderSeq: number | null + reason: DeepChatTapeViewExcludedReason +} + +export interface DeepChatTapeViewTokenBudget { + contextLength: number + requestedMaxTokens: number + effectiveMaxTokens: number + reserveTokens: number + toolReserveTokens: number + estimatedPromptTokens: number +} + +export interface DeepChatTapeViewHashes { + promptHash: string + toolDefinitionsHash: string + manifestHash: string +} + +export interface DeepChatTapeViewMeta { + providerId: string + modelId: string + summaryCursorOrderSeq: number + supportsVision: boolean + supportsAudioInput: boolean + traceDebugEnabled: boolean +} + +export interface DeepChatTapeViewManifest { + schemaVersion: 1 + viewId: string + sessionId: string + messageId: string + requestSeq: number + taskType: DeepChatTapeViewTaskType + policy: DeepChatTapeViewPolicy + policyVersion: number | null + contextBuilderVersion: 'legacy-v1' + latestEntryId: number + anchorEntryIds: number[] + included: DeepChatTapeViewEntryRef[] + excluded: DeepChatTapeViewExcludedRef[] + tokenBudget: DeepChatTapeViewTokenBudget + hashes: DeepChatTapeViewHashes + meta: DeepChatTapeViewMeta + assembledAt: number +} + +export interface DeepChatTapeViewManifestRecord { + sessionId: string + messageId: string + requestSeq: number + entryId: number + createdAt: number + manifest: DeepChatTapeViewManifest +} diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 22d428405..04323bcac 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -1492,6 +1492,98 @@ describe('AgentRuntimePresenter', () => { ) }) + it('persists view manifests before each provider request with monotonic request sequences', async () => { + configPresenter.getSetting.mockImplementation((key: string) => + key === 'traceDebugEnabled' ? true : undefined + ) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'Hello') + + const callArgs = (processStream as ReturnType).mock.calls[0][0] + const providerCoreStream = llmProvider.getProviderInstance.mock.results[0].value.coreStream + const appendEventCallsBeforeProviderTurn = + sqlitePresenter.deepchatTapeEntriesTable.appendEvent.mock.calls.length + + for await (const _event of callArgs.coreStream( + callArgs.messages, + callArgs.modelId, + callArgs.modelConfig, + callArgs.temperature, + callArgs.maxTokens, + callArgs.tools + )) { + } + for await (const _event of callArgs.coreStream( + callArgs.messages, + callArgs.modelId, + callArgs.modelConfig, + callArgs.temperature, + callArgs.maxTokens, + callArgs.tools + )) { + } + + const firstManifestAppendOrder = + sqlitePresenter.deepchatTapeEntriesTable.appendEvent.mock.invocationCallOrder[ + appendEventCallsBeforeProviderTurn + ] + const firstProviderCallOrder = providerCoreStream.mock.invocationCallOrder[0] + expect(firstManifestAppendOrder).toBeLessThan(firstProviderCallOrder) + + const manifestRows = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s1') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + const manifests = manifestRows.map((row: any) => JSON.parse(row.payload_json).data.manifest) + + expect(manifestRows).toHaveLength(2) + expect(manifestRows.map((row: any) => row.source_seq)).toEqual([1, 2]) + expect(manifests.map((manifest: any) => manifest.requestSeq)).toEqual([1, 2]) + expect(manifests[0]).toMatchObject({ + taskType: 'chat', + policy: 'legacy_context_v1', + policyVersion: 1, + meta: { + traceDebugEnabled: true + } + }) + expect(manifests[1]).toMatchObject({ + taskType: 'tool_loop', + policy: 'tool_loop_shadow', + policyVersion: null + }) + expect(manifests[0].hashes.promptHash).toHaveLength(64) + expect(manifests[1].hashes.toolDefinitionsHash).toHaveLength(64) + }) + + it('continues provider requests when view manifest persistence fails', async () => { + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'Hello') + + const callArgs = (processStream as ReturnType).mock.calls[0][0] + const providerCoreStream = llmProvider.getProviderInstance.mock.results[0].value.coreStream + const loggerWarnMock = vi.mocked(logger.warn) + loggerWarnMock.mockClear() + sqlitePresenter.deepchatTapeEntriesTable.appendEvent.mockImplementation(() => { + throw new Error('manifest write failed') + }) + + for await (const _event of callArgs.coreStream( + callArgs.messages, + callArgs.modelId, + callArgs.modelConfig, + callArgs.temperature, + callArgs.maxTokens, + callArgs.tools + )) { + } + + expect(providerCoreStream).toHaveBeenCalledTimes(1) + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.stringContaining('Failed to persist tape view manifest') + ) + }) + it('emits and clears an ephemeral rate-limit message while waiting for the provider gate', async () => { llmProvider.executeWithRateLimit.mockImplementation( async (_providerId: string, options?: { onQueued?: (snapshot: any) => void }) => { @@ -3808,8 +3900,18 @@ describe('AgentRuntimePresenter', () => { estimateMessagesTokens(providerMessages) + estimateToolReserveTokens(providerTools) + providerMaxTokens + const manifestRows = sqlitePresenter.deepchatTapeEntriesTable + .getBySession('s1') + .filter((row: any) => row.kind === 'event' && row.name === 'view/assembled') + const pressureManifest = JSON.parse(manifestRows[0].payload_json).data.manifest expect(llmProvider.generateText).toHaveBeenCalled() + expect(pressureManifest).toMatchObject({ + taskType: 'chat', + requestSeq: 1, + policy: 'context_pressure_recovery_shadow', + policyVersion: null + }) expect(providerMessages[0].content).toContain('## Conversation Summary') expect(providerMaxTokens).toBeLessThan(4096) expect(totalRequestTokens).toBeLessThanOrEqual(getUsableContextLength(8192)) diff --git a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts index bb2df629c..e856f4ce4 100644 --- a/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts +++ b/test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { buildContext, buildResumeContext, + buildResumeContextWithMetadata, fitMessagesToContextWindow, truncateContext } from '@/presenter/agentRuntimePresenter/contextBuilder' @@ -1162,6 +1163,62 @@ describe('buildResumeContext', () => { ]) }) + it('does not duplicate empty formatted resume records as out of budget exclusions', () => { + const emptyRecord = { + id: 'empty-user', + sessionId: 's1', + orderSeq: 1, + role: 'user' as const, + content: JSON.stringify({ text: '', files: [], links: [], search: false, think: false }), + status: 'sent' as const, + isContextEdge: 0, + metadata: '{}', + createdAt: Date.now(), + updatedAt: Date.now() + } + const messages = [ + emptyRecord, + makeUserRecord(2, 'recent user'), + { + id: 'resume-target', + sessionId: 's1', + orderSeq: 3, + role: 'assistant' as const, + content: JSON.stringify([ + { type: 'content', content: 'partial answer', status: 'success', timestamp: Date.now() } + ]), + status: 'pending' as const, + isContextEdge: 0, + metadata: '{}', + createdAt: Date.now(), + updatedAt: Date.now() + } + ] + const store = createMockMessageStore(messages) + + const result = buildResumeContextWithMetadata( + 's1', + 'resume-target', + '', + 10000, + 4096, + store, + false, + { + fallbackProtectedTurnCount: 1 + } + ) + + expect( + result.metadata.excludedRecords.filter((item) => item.record.id === 'empty-user') + ).toEqual([ + { + record: emptyRecord, + reason: 'empty_after_formatting' + } + ]) + }) + it('includes prior assistant error records when building resume context', () => { const messages = [ makeUserRecord(1, 'previous user'), diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 58ae4fbb1..b74b3b824 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { buildContext } from '@/presenter/agentRuntimePresenter/contextBuilder' import { DeepChatTapeService } from '@/presenter/agentRuntimePresenter/tapeService' +import { createTapeViewManifest } from '@/presenter/agentRuntimePresenter/tapeViewManifest' import { appendMessageReplacementToTape, appendMessageRetractionToTape @@ -181,6 +182,35 @@ function createRecord(overrides: Partial): ChatMessageRecord } } +function createTraceRow(overrides: Record = {}) { + return { + id: 'trace-1', + message_id: 'a1', + session_id: 's1', + provider_id: 'openai', + model_id: 'gpt-4o', + request_seq: 1, + endpoint: 'https://api.openai.test/v1/chat/completions', + headers_json: '{"authorization":"[redacted]"}', + body_json: '{"messages":[{"role":"user","content":"hello"}]}', + truncated: 0, + created_at: 300, + ...overrides + } +} + +function createTapeService(table: unknown, traceRows: Array> = []) { + return new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatMessageTracesTable: { + listByMessageId: vi.fn((messageId: string) => + traceRows.filter((row) => row.message_id === messageId) + ) + }, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) +} + describe('DeepChatTapeService', () => { it('backfills message and tool facts idempotently before returning tape records', () => { const { table, entries } = createTapeTableMock() @@ -432,6 +462,294 @@ describe('DeepChatTapeService', () => { }) }) + it('stores and lists view manifests as idempotent tape events', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + const messageStore = { + getMessages: vi.fn().mockReturnValue([createRecord({ id: 'u1', orderSeq: 1 })]) + } + + service.ensureSessionTapeReady('s1', messageStore as any) + const sourceMaps = service.getViewManifestSourceMaps('s1') + const manifest = createTapeViewManifest({ + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + taskType: 'chat', + policy: 'legacy_context_v1', + policyVersion: 1, + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [], + latestEntryId: sourceMaps.latestEntryId, + anchorEntryIds: sourceMaps.anchorEntryIds, + included: [ + { + entryId: sourceMaps.entryIdByMessageId.get('u1') ?? null, + messageId: 'u1', + orderSeq: 1, + role: 'user', + source: 'tape', + reason: 'selected_history' + } + ], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: true, + supportsAudioInput: false, + traceDebugEnabled: false, + assembledAt: 200 + }) + + const first = service.appendViewManifest(manifest) + const second = service.appendViewManifest(manifest) + + expect(second.entry_id).toBe(first.entry_id) + expect(entries.filter((entry) => entry.name === 'view/assembled')).toHaveLength(1) + expect(JSON.parse(first.meta_json)).toMatchObject({ + policy: 'legacy_context_v1', + policyVersion: 1 + }) + expect(service.listViewManifestsByMessage('s1', 'a1')).toMatchObject([ + { + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + entryId: first.entry_id, + manifest: { + hashes: { + manifestHash: manifest.hashes.manifestHash + }, + policy: 'legacy_context_v1', + policyVersion: 1, + included: [ + { + messageId: 'u1', + entryId: sourceMaps.entryIdByMessageId.get('u1') + } + ] + } + } + ]) + }) + + it('filters malformed view manifest rows when listing by message', () => { + const { table } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + + table.appendEvent({ + sessionId: 's1', + name: 'view/assembled', + source: { + type: 'runtime_event', + id: 'a1', + seq: 1 + }, + data: { + manifest: { + schemaVersion: 1, + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + included: 'not-an-array' + } + } + }) + + expect(service.listViewManifestsByMessage('s1', 'a1')).toEqual([]) + }) + + it('throws a clear error when appending live messages without a tape table', () => { + const service = new DeepChatTapeService({} as any) + + expect(() => service.appendMessageRecord(createRecord({ id: 'u1' }))).toThrow( + 'Tape table is not available.' + ) + }) + + it('exports replay slices with metadata-only payloads by default', () => { + const { table } = createTapeTableMock() + const service = createTapeService(table, [createTraceRow()]) + const messageStore = { + getMessages: vi.fn().mockReturnValue([createRecord({ id: 'u1', orderSeq: 1 })]) + } + + service.ensureSessionTapeReady('s1', messageStore as any) + const sourceMaps = service.getViewManifestSourceMaps('s1') + const manifest = createTapeViewManifest({ + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + taskType: 'chat', + policy: 'legacy_context_v1', + policyVersion: 1, + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [], + latestEntryId: sourceMaps.latestEntryId, + anchorEntryIds: sourceMaps.anchorEntryIds, + included: [ + { + entryId: sourceMaps.entryIdByMessageId.get('u1') ?? null, + messageId: 'u1', + orderSeq: 1, + role: 'user', + source: 'tape', + reason: 'selected_history' + } + ], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: true, + supportsAudioInput: false, + traceDebugEnabled: true, + assembledAt: 200 + }) + const manifestEntry = service.appendViewManifest(manifest) + + const slice = service.exportReplaySlice('s1', 'a1') + + expect(slice).toMatchObject({ + schemaVersion: 1, + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + mode: 'trace_bound', + refs: { + manifestEntryId: manifestEntry.entry_id, + includedEntryIds: [sourceMaps.entryIdByMessageId.get('u1')], + anchorEntryIds: sourceMaps.anchorEntryIds + }, + hashes: { + manifestHash: manifest.hashes.manifestHash + } + }) + expect(slice?.hashes.sliceHash).toHaveLength(64) + expect(slice?.trace?.bodyHash).toHaveLength(64) + expect(slice?.trace?.bodyJson).toBeUndefined() + expect(slice?.entries.some((entry) => entry.entryId === manifestEntry.entry_id)).toBe(true) + expect( + slice?.entries.every((entry) => entry.payload === undefined && entry.meta === undefined) + ).toBe(true) + }) + + it('exports explicit replay request sequences with opt-in payloads', () => { + const { table } = createTapeTableMock() + const service = createTapeService(table, [ + createTraceRow({ id: 'trace-1', request_seq: 1 }), + createTraceRow({ + id: 'trace-2', + request_seq: 2, + body_json: '{"messages":[{"role":"tool","content":"done"}]}' + }) + ]) + const messageStore = { + getMessages: vi.fn().mockReturnValue([createRecord({ id: 'u1', orderSeq: 1 })]) + } + + service.ensureSessionTapeReady('s1', messageStore as any) + const sourceMaps = service.getViewManifestSourceMaps('s1') + const baseManifestInput = { + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + taskType: 'chat' as const, + policy: 'legacy_context_v1' as const, + policyVersion: 1, + messages: [{ role: 'user' as const, content: 'hello' }], + tools: [], + latestEntryId: sourceMaps.latestEntryId, + anchorEntryIds: sourceMaps.anchorEntryIds, + included: [ + { + entryId: sourceMaps.entryIdByMessageId.get('u1') ?? null, + messageId: 'u1', + orderSeq: 1, + role: 'user', + source: 'tape', + reason: 'selected_history' + } + ], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: true, + supportsAudioInput: false, + traceDebugEnabled: true, + assembledAt: 200 + } + const firstManifest = createTapeViewManifest(baseManifestInput) + const secondManifest = createTapeViewManifest({ + ...baseManifestInput, + requestSeq: 2, + taskType: 'tool_loop', + policy: 'tool_loop_shadow', + policyVersion: null, + assembledAt: 250 + }) + service.appendViewManifest(firstManifest) + service.appendViewManifest(secondManifest) + + const latest = service.exportReplaySlice('s1', 'a1') + const first = service.exportReplaySlice('s1', 'a1', { + requestSeq: 1, + includeTapePayloads: true, + includeTracePayload: true + }) + + expect(latest?.requestSeq).toBe(2) + expect(first?.requestSeq).toBe(1) + expect(first?.trace?.bodyJson).toContain('"hello"') + expect(first?.entries.some((entry) => entry.payload?.record)).toBe(true) + expect(first?.entries.some((entry) => entry.meta?.source === 'backfill')).toBe(true) + }) + + it('returns null when exporting a replay slice without a manifest', () => { + const { table } = createTapeTableMock() + const service = createTapeService(table, [createTraceRow()]) + + expect(service.exportReplaySlice('s1', 'a1')).toBeNull() + }) + + it('rejects non-positive replay request sequences', () => { + const { table } = createTapeTableMock() + const service = createTapeService(table, [createTraceRow()]) + + expect(() => service.exportReplaySlice('s1', 'a1', { requestSeq: 0 })).toThrow( + 'requestSeq must be a positive integer.' + ) + }) + it('keeps pending message records for resume but hides pending tool facts from search', () => { const { table } = createTapeTableMock() const pendingBlocks = [ diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts new file mode 100644 index 000000000..a13385a9c --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/tapeViewAssembler.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import { + buildContextWithMetadata, + buildResumeContextWithMetadata +} from '@/presenter/agentRuntimePresenter/contextBuilder' +import { + buildTapeChatView, + buildTapeResumeView, + getTapeContextHistoryRecords, + TAPE_VIEW_ASSEMBLER_VERSION, + TAPE_VIEW_HISTORY_SOURCE +} from '@/presenter/agentRuntimePresenter/tapeViewAssembler' +import { + LEGACY_TAPE_VIEW_POLICY_ID, + LEGACY_TAPE_VIEW_POLICY_VERSION, + type TapeViewPolicy +} from '@/presenter/agentRuntimePresenter/tapeViewPolicy' + +vi.mock('tokenx', () => ({ + approximateTokenSize: vi.fn((text: string) => Math.ceil(text.length / 4)) +})) + +function createMockMessageStore(messages: ChatMessageRecord[] = []) { + return { + getMessages: vi.fn().mockReturnValue(messages) + } as any +} + +function makeUserRecord(orderSeq: number, text: string): ChatMessageRecord { + return { + id: `user-${orderSeq}`, + sessionId: 's1', + orderSeq, + role: 'user', + content: JSON.stringify({ text, files: [], links: [], search: false, think: false }), + status: 'sent', + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq * 100, + updatedAt: orderSeq * 100 + } +} + +function makeAssistantRecord( + orderSeq: number, + text: string, + status: ChatMessageRecord['status'] = 'sent' +): ChatMessageRecord { + return { + id: orderSeq === 4 ? 'resume-target' : `asst-${orderSeq}`, + sessionId: 's1', + orderSeq, + role: 'assistant', + content: JSON.stringify([ + { type: 'content', content: text, status: 'success', timestamp: orderSeq * 100 } + ]), + status, + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq * 100, + updatedAt: orderSeq * 100 + } +} + +describe('TapeViewAssembler', () => { + it('matches legacy chat context assembly while recording tape provenance', () => { + const records = [ + makeUserRecord(1, 'old user'), + makeAssistantRecord(2, 'old assistant'), + makeUserRecord(3, 'recent user') + ] + const store = createMockMessageStore(records) + const historyRecords = getTapeContextHistoryRecords(records) + const options = { + summaryCursorOrderSeq: 2, + extraReserveTokens: 16, + supportsAudioInput: false + } + + const legacy = buildContextWithMetadata('s1', 'next user', 'System', 1000, 100, store, false, { + ...options, + historyRecords + }) + const assembled = buildTapeChatView({ + sessionId: 's1', + newUserContent: 'next user', + systemPrompt: 'System', + contextLength: 1000, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords, + options + }) + + expect(assembled.messages).toEqual(legacy.messages) + expect(assembled.metadata).toEqual(legacy.metadata) + expect(assembled.historyRecords).toEqual(historyRecords) + expect(assembled.assemblerVersion).toBe(TAPE_VIEW_ASSEMBLER_VERSION) + expect(assembled.historySource).toBe(TAPE_VIEW_HISTORY_SOURCE) + expect(assembled.policyId).toBe(LEGACY_TAPE_VIEW_POLICY_ID) + expect(assembled.policyVersion).toBe(LEGACY_TAPE_VIEW_POLICY_VERSION) + expect(assembled.policySelectionReason).toBe('default') + }) + + it('matches legacy resume context assembly while recording tape provenance', () => { + const records = [ + makeUserRecord(1, 'old user'), + makeAssistantRecord(2, 'old assistant'), + makeUserRecord(3, 'recent user'), + makeAssistantRecord(4, 'partial answer', 'pending') + ] + const store = createMockMessageStore(records) + const options = { + summaryCursorOrderSeq: 1, + fallbackProtectedTurnCount: 1, + extraReserveTokens: 12, + supportsAudioInput: false + } + + const legacy = buildResumeContextWithMetadata( + 's1', + 'resume-target', + 'System', + 260, + 100, + store, + false, + { + ...options, + historyRecords: records + } + ) + const assembled = buildTapeResumeView({ + sessionId: 's1', + assistantMessageId: 'resume-target', + systemPrompt: 'System', + contextLength: 260, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + options + }) + + expect(assembled.messages).toEqual(legacy.messages) + expect(assembled.metadata).toEqual(legacy.metadata) + expect(assembled.historyRecords).toEqual(records) + expect(assembled.assemblerVersion).toBe(TAPE_VIEW_ASSEMBLER_VERSION) + expect(assembled.historySource).toBe(TAPE_VIEW_HISTORY_SOURCE) + expect(assembled.policyId).toBe(LEGACY_TAPE_VIEW_POLICY_ID) + expect(assembled.policyVersion).toBe(LEGACY_TAPE_VIEW_POLICY_VERSION) + expect(assembled.policySelectionReason).toBe('default') + }) + + it('records requested and fallback policy selection reasons', () => { + const records = [makeUserRecord(1, 'old user')] + const store = createMockMessageStore(records) + + const requested = buildTapeChatView({ + sessionId: 's1', + newUserContent: 'next user', + systemPrompt: '', + contextLength: 1000, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + requestedPolicyId: LEGACY_TAPE_VIEW_POLICY_ID + }) + + const fallback = buildTapeChatView({ + sessionId: 's1', + newUserContent: 'next user', + systemPrompt: '', + contextLength: 1000, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + requestedPolicyId: 'missing-policy' + }) + + expect(requested.policySelectionReason).toBe('requested') + expect(fallback.policySelectionReason).toBe('fallback_default') + expect(fallback.policyId).toBe(LEGACY_TAPE_VIEW_POLICY_ID) + }) + + it('delegates assembly to an injected policy', () => { + const records = [makeUserRecord(1, 'old user')] + const store = createMockMessageStore(records) + const customPolicy = { + id: LEGACY_TAPE_VIEW_POLICY_ID, + version: LEGACY_TAPE_VIEW_POLICY_VERSION, + buildChat: vi.fn().mockReturnValue({ + messages: [{ role: 'user', content: 'from policy' }], + metadata: { + includedRecords: [], + excludedRecords: [], + includesSystemPrompt: false + } + }), + buildResume: vi.fn() + } satisfies TapeViewPolicy + + const assembled = buildTapeChatView({ + sessionId: 's1', + newUserContent: 'next user', + systemPrompt: '', + contextLength: 1000, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + policy: customPolicy + }) + + expect(customPolicy.buildChat).toHaveBeenCalledOnce() + expect(assembled.messages).toEqual([{ role: 'user', content: 'from policy' }]) + expect(assembled.policyId).toBe(LEGACY_TAPE_VIEW_POLICY_ID) + expect(assembled.policySelectionReason).toBe('injected') + }) +}) diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts new file mode 100644 index 000000000..5b430585a --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/tapeViewManifest.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import { + buildIncludedRefs, + buildSyntheticRequestRefs, + createTapeViewManifest, + hashJson, + resolveTapeViewManifestPolicy +} from '@/presenter/agentRuntimePresenter/tapeViewManifest' + +function createRecord(overrides: Partial): ChatMessageRecord { + return { + id: 'm1', + sessionId: 's1', + orderSeq: 1, + role: 'user', + content: 'secret prompt content', + status: 'sent', + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: 100, + updatedAt: 100, + ...overrides + } +} + +describe('tapeViewManifest', () => { + it('hashes JSON with stable object key ordering', () => { + expect(hashJson({ b: 1, a: { d: 4, c: 3 } })).toBe(hashJson({ a: { c: 3, d: 4 }, b: 1 })) + }) + + it('builds refs from context metadata without copying raw message content', () => { + const refs = buildIncludedRefs( + { + includesSystemPrompt: true, + includedRecords: [ + { + record: createRecord({ id: 'u1', orderSeq: 3, content: 'do not persist this text' }), + reason: 'selected_history' + } + ], + excludedRecords: [], + newUserMessageId: 'u2' + }, + { + entryIdByMessageId: new Map([ + ['u1', 11], + ['u2', 12] + ]) + } + ) + + expect(refs).toMatchObject([ + { entryId: null, role: 'system', reason: 'system_prompt', source: 'synthetic' }, + { entryId: 11, messageId: 'u1', orderSeq: 3, reason: 'selected_history', source: 'tape' }, + { entryId: 12, messageId: 'u2', reason: 'new_user_input', source: 'tape' } + ]) + expect(JSON.stringify(refs)).not.toContain('do not persist this text') + }) + + it('creates deterministic prompt and manifest hashes without storing prompt bodies', () => { + const input = { + sessionId: 's1', + messageId: 'a1', + requestSeq: 1, + taskType: 'chat' as const, + policy: 'legacy_context_v1' as const, + policyVersion: 1, + messages: [{ role: 'user' as const, content: 'secret prompt content' }], + tools: [], + latestEntryId: 7, + anchorEntryIds: [1], + included: [ + { + entryId: 2, + messageId: 'u1', + orderSeq: 1, + role: 'user' as const, + source: 'tape' as const, + reason: 'selected_history' as const + } + ], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0 + }, + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: true, + supportsAudioInput: false, + traceDebugEnabled: false, + assembledAt: 123 + } + + const first = createTapeViewManifest(input) + const second = createTapeViewManifest(input) + + expect(first.hashes).toEqual(second.hashes) + expect(first.policy).toBe('legacy_context_v1') + expect(first.policyVersion).toBe(1) + expect(first.hashes.manifestHash).toHaveLength(64) + expect(first.tokenBudget.estimatedPromptTokens).toBeGreaterThan(0) + expect(JSON.stringify(first)).not.toContain('secret prompt content') + }) + + it('resolves initial Tape policy provenance and request-level shadow policies', () => { + expect( + resolveTapeViewManifestPolicy({ + recoveredFromContextPressure: false, + isInitialViewRequest: true, + viewPolicy: 'legacy_context_v1', + viewPolicyVersion: 1 + }) + ).toEqual({ + policy: 'legacy_context_v1', + policyVersion: 1 + }) + + expect( + resolveTapeViewManifestPolicy({ + recoveredFromContextPressure: false, + isInitialViewRequest: true, + viewPolicy: 'legacy_context_v1' + }) + ).toEqual({ + policy: 'legacy_context_v1', + policyVersion: null + }) + + expect( + resolveTapeViewManifestPolicy({ + recoveredFromContextPressure: false, + isInitialViewRequest: false, + viewPolicy: 'legacy_context_v1', + viewPolicyVersion: 1 + }) + ).toEqual({ + policy: 'tool_loop_shadow', + policyVersion: null + }) + + expect( + resolveTapeViewManifestPolicy({ + recoveredFromContextPressure: true, + isInitialViewRequest: true, + viewPolicy: 'legacy_context_v1', + viewPolicyVersion: 1 + }) + ).toEqual({ + policy: 'context_pressure_recovery_shadow', + policyVersion: null + }) + }) + + it('builds synthetic request refs with role-specific reasons', () => { + expect( + buildSyntheticRequestRefs([ + { role: 'system', content: 'system' }, + { role: 'user', content: 'question' }, + { role: 'tool', content: 'tool output' } + ]) + ).toMatchObject([ + { role: 'system', reason: 'system_prompt', source: 'synthetic' }, + { role: 'user', reason: 'selected_history', source: 'synthetic' }, + { role: 'tool', reason: 'tool_loop_message', source: 'synthetic' } + ]) + }) +}) diff --git a/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts b/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts new file mode 100644 index 000000000..fc5045d0d --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/tapeViewPolicy.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ChatMessageRecord } from '@shared/types/agent-interface' +import { + buildContextWithMetadata, + buildResumeContextWithMetadata +} from '@/presenter/agentRuntimePresenter/contextBuilder' +import { + LEGACY_TAPE_VIEW_POLICY_ID, + LEGACY_TAPE_VIEW_POLICY_VERSION, + getTapeViewPolicy, + legacyTapeViewPolicy, + listTapeViewPolicies, + resolveTapeViewPolicy +} from '@/presenter/agentRuntimePresenter/tapeViewPolicy' + +vi.mock('tokenx', () => ({ + approximateTokenSize: vi.fn((text: string) => Math.ceil(text.length / 4)) +})) + +function createMockMessageStore(messages: ChatMessageRecord[] = []) { + return { + getMessages: vi.fn().mockReturnValue(messages) + } as any +} + +function makeUserRecord(orderSeq: number, text: string): ChatMessageRecord { + return { + id: `user-${orderSeq}`, + sessionId: 's1', + orderSeq, + role: 'user', + content: JSON.stringify({ text, files: [], links: [], search: false, think: false }), + status: 'sent', + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq * 100, + updatedAt: orderSeq * 100 + } +} + +function makeAssistantRecord( + orderSeq: number, + text: string, + status: ChatMessageRecord['status'] = 'sent' +): ChatMessageRecord { + return { + id: orderSeq === 4 ? 'resume-target' : `asst-${orderSeq}`, + sessionId: 's1', + orderSeq, + role: 'assistant', + content: JSON.stringify([ + { type: 'content', content: text, status: 'success', timestamp: orderSeq * 100 } + ]), + status, + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq * 100, + updatedAt: orderSeq * 100 + } +} + +describe('legacyTapeViewPolicy', () => { + it('matches legacy chat context builder output', () => { + const records = [ + makeUserRecord(1, 'old user'), + makeAssistantRecord(2, 'old assistant'), + makeUserRecord(3, 'recent user') + ] + const store = createMockMessageStore(records) + const input = { + sessionId: 's1', + newUserContent: 'next user', + systemPrompt: 'System', + contextLength: 1000, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + options: { + summaryCursorOrderSeq: 2, + extraReserveTokens: 16, + supportsAudioInput: false + } + } + + const legacy = buildContextWithMetadata( + input.sessionId, + input.newUserContent, + input.systemPrompt, + input.contextLength, + input.reserveTokens, + input.messageStore, + input.supportsVision, + { + ...input.options, + historyRecords: input.historyRecords + } + ) + const policyResult = legacyTapeViewPolicy.buildChat(input) + + expect(legacyTapeViewPolicy.id).toBe(LEGACY_TAPE_VIEW_POLICY_ID) + expect(legacyTapeViewPolicy.version).toBe(LEGACY_TAPE_VIEW_POLICY_VERSION) + expect(policyResult).toEqual(legacy) + }) + + it('matches legacy resume context builder output', () => { + const records = [ + makeUserRecord(1, 'old user'), + makeAssistantRecord(2, 'old assistant'), + makeUserRecord(3, 'recent user'), + makeAssistantRecord(4, 'partial answer', 'pending') + ] + const store = createMockMessageStore(records) + const input = { + sessionId: 's1', + assistantMessageId: 'resume-target', + systemPrompt: 'System', + contextLength: 260, + reserveTokens: 100, + messageStore: store, + supportsVision: false, + historyRecords: records, + options: { + summaryCursorOrderSeq: 1, + fallbackProtectedTurnCount: 1, + extraReserveTokens: 12, + supportsAudioInput: false + } + } + + const legacy = buildResumeContextWithMetadata( + input.sessionId, + input.assistantMessageId, + input.systemPrompt, + input.contextLength, + input.reserveTokens, + input.messageStore, + input.supportsVision, + { + ...input.options, + historyRecords: input.historyRecords + } + ) + const policyResult = legacyTapeViewPolicy.buildResume(input) + + expect(policyResult).toEqual(legacy) + }) +}) + +describe('TapeViewPolicy registry', () => { + it('lists and resolves the built-in legacy policy', () => { + expect(listTapeViewPolicies()).toEqual([legacyTapeViewPolicy]) + expect(getTapeViewPolicy(LEGACY_TAPE_VIEW_POLICY_ID)).toBe(legacyTapeViewPolicy) + expect(getTapeViewPolicy(` ${LEGACY_TAPE_VIEW_POLICY_ID} `)).toBe(legacyTapeViewPolicy) + expect(getTapeViewPolicy('missing-policy')).toBeNull() + expect(getTapeViewPolicy('')).toBeNull() + }) + + it('resolves default, requested, and fallback selections', () => { + expect(resolveTapeViewPolicy()).toEqual({ + policy: legacyTapeViewPolicy, + requestedPolicyId: null, + reason: 'default' + }) + + expect(resolveTapeViewPolicy({ requestedPolicyId: LEGACY_TAPE_VIEW_POLICY_ID })).toEqual({ + policy: legacyTapeViewPolicy, + requestedPolicyId: LEGACY_TAPE_VIEW_POLICY_ID, + reason: 'requested' + }) + + expect(resolveTapeViewPolicy({ requestedPolicyId: 'missing-policy' })).toEqual({ + policy: legacyTapeViewPolicy, + requestedPolicyId: 'missing-policy', + reason: 'fallback_default' + }) + }) +}) diff --git a/test/main/presenter/presenterCallErrorHandler.test.ts b/test/main/presenter/presenterCallErrorHandler.test.ts index b93eaa3a2..43f6ea757 100644 --- a/test/main/presenter/presenterCallErrorHandler.test.ts +++ b/test/main/presenter/presenterCallErrorHandler.test.ts @@ -5,18 +5,19 @@ const mocks = vi.hoisted(() => ({ sendToWebContents: vi.fn() })) -vi.mock('@/eventbus', () => ({ - eventBus: { - sendToWebContents: mocks.sendToWebContents - } -})) - const loadModule = async () => await import('../../../src/main/presenter/presenterCallErrorHandler') +const loadEventPublisher = async () => await import('../../../src/main/routes/publishDeepchatEvent') describe('presenterCallErrorHandler', () => { beforeEach(async () => { vi.resetModules() mocks.sendToWebContents.mockReset() + mocks.sendToWebContents.mockResolvedValue(true) + const { setDeepchatEventWindowPresenter } = await loadEventPublisher() + setDeepchatEventWindowPresenter({ + sendToAllWindows: vi.fn(), + sendToWebContents: mocks.sendToWebContents + }) const { resetPresenterCallErrorStateForTests } = await loadModule() resetPresenterCallErrorStateForTests() }) diff --git a/test/renderer/components/trace/TraceDialog.test.ts b/test/renderer/components/trace/TraceDialog.test.ts index e865ada59..202332867 100644 --- a/test/renderer/components/trace/TraceDialog.test.ts +++ b/test/renderer/components/trace/TraceDialog.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -const { listMessageTracesMock } = vi.hoisted(() => ({ - listMessageTracesMock: vi.fn() -})) +const { listMessageTraceDiagnosticsMock, listMessageTracesMock, listMessageViewManifestsMock } = + vi.hoisted(() => ({ + listMessageTraceDiagnosticsMock: vi.fn(), + listMessageTracesMock: vi.fn(), + listMessageViewManifestsMock: vi.fn() + })) vi.mock('@api/SessionClient', () => ({ createSessionClient: vi.fn(() => ({ - listMessageTraces: listMessageTracesMock + listMessageTraceDiagnostics: listMessageTraceDiagnosticsMock, + listMessageTraces: listMessageTracesMock, + listMessageViewManifests: listMessageViewManifestsMock })) })) @@ -57,6 +62,21 @@ vi.mock( { virtual: true } ) +vi.mock( + '@shadcn/components/ui/tabs', + () => ({ + Tabs: { name: 'Tabs', template: '
' }, + TabsContent: { name: 'TabsContent', template: '
' }, + TabsList: { name: 'TabsList', template: '
' }, + TabsTrigger: { + name: 'TabsTrigger', + props: ['value'], + template: '' + } + }), + { virtual: true } +) + vi.mock( '@shadcn/components/ui/spinner', () => ({ @@ -81,6 +101,51 @@ vi.mock('vue-i18n', () => ({ import TraceDialog from '@/components/trace/TraceDialog.vue' +const makeManifestRecord = (requestSeq: number, viewId: string) => ({ + sessionId: 's1', + messageId: 'm1', + requestSeq, + entryId: requestSeq, + createdAt: 2000, + manifest: { + schemaVersion: 1, + viewId, + sessionId: 's1', + messageId: 'm1', + requestSeq, + taskType: 'chat', + policy: 'legacy_context_v1', + policyVersion: 1, + contextBuilderVersion: 'legacy-v1', + latestEntryId: 8, + anchorEntryIds: [1], + included: [], + excluded: [], + tokenBudget: { + contextLength: 1000, + requestedMaxTokens: 100, + effectiveMaxTokens: 100, + reserveTokens: 100, + toolReserveTokens: 0, + estimatedPromptTokens: 12 + }, + hashes: { + promptHash: 'prompt_hash', + toolDefinitionsHash: 'tool_hash', + manifestHash: 'manifest_hash' + }, + meta: { + providerId: 'openai', + modelId: 'gpt-4o', + summaryCursorOrderSeq: 1, + supportsVision: true, + supportsAudioInput: false, + traceDebugEnabled: false + }, + assembledAt: 2000 + } +}) + const mountDialog = () => mount(TraceDialog, { props: { @@ -90,42 +155,52 @@ const mountDialog = () => }) describe('TraceDialog', () => { + beforeEach(() => { + listMessageTraceDiagnosticsMock.mockReset() + listMessageTracesMock.mockReset() + listMessageViewManifestsMock.mockReset() + listMessageTraceDiagnosticsMock.mockResolvedValue({ traces: [], manifests: [] }) + }) + it('shows latest trace by default and supports switching trace history', async () => { - listMessageTracesMock.mockResolvedValue([ - { - id: 't2', - messageId: 'm1', - sessionId: 's1', - providerId: 'openai', - modelId: 'gpt-4o', - requestSeq: 2, - endpoint: 'https://api.example.com/second', - headersJson: '{"x":"2"}', - bodyJson: '{"b":2}', - truncated: false, - createdAt: 2000 - }, - { - id: 't1', - messageId: 'm1', - sessionId: 's1', - providerId: 'openai', - modelId: 'gpt-4o', - requestSeq: 1, - endpoint: 'https://api.example.com/first', - headersJson: '{"x":"1"}', - bodyJson: '{"b":1}', - truncated: false, - createdAt: 1000 - } - ]) + listMessageTraceDiagnosticsMock.mockResolvedValue({ + traces: [ + { + id: 't2', + messageId: 'm1', + sessionId: 's1', + providerId: 'openai', + modelId: 'gpt-4o', + requestSeq: 2, + endpoint: 'https://api.example.com/second', + headersJson: '{"x":"2"}', + bodyJson: '{"b":2}', + truncated: false, + createdAt: 2000 + }, + { + id: 't1', + messageId: 'm1', + sessionId: 's1', + providerId: 'openai', + modelId: 'gpt-4o', + requestSeq: 1, + endpoint: 'https://api.example.com/first', + headersJson: '{"x":"1"}', + bodyJson: '{"b":1}', + truncated: false, + createdAt: 1000 + } + ], + manifests: [] + }) const wrapper = mountDialog() await wrapper.setProps({ messageId: 'm1' }) await flushPromises() - expect(listMessageTracesMock).toHaveBeenCalledWith('m1') + expect(listMessageTraceDiagnosticsMock).toHaveBeenCalledWith('m1') expect(wrapper.text()).toContain('https://api.example.com/second') const historyButton = wrapper.findAll('button').find((btn) => btn.text().trim() === '#1') @@ -136,4 +211,69 @@ describe('TraceDialog', () => { expect(wrapper.text()).toContain('https://api.example.com/first') }) + + it('shows view manifest diagnostics when request traces are empty', async () => { + listMessageTraceDiagnosticsMock.mockResolvedValue({ + traces: [], + manifests: [makeManifestRecord(1, 'view_abc')] + }) + + const wrapper = mountDialog() + + await wrapper.setProps({ messageId: 'm1' }) + await flushPromises() + + expect(listMessageTraceDiagnosticsMock).toHaveBeenCalledWith('m1') + expect(wrapper.text()).toContain('view_abc') + expect(wrapper.text()).toContain('legacy_context_v1') + expect(wrapper.text()).toContain('traceDialog.policyVersion') + expect(wrapper.text()).toContain('1') + }) + + it('does not fall back to a different request when selected manifest has no trace', async () => { + listMessageTraceDiagnosticsMock.mockResolvedValue({ + traces: [ + { + id: 't2', + messageId: 'm1', + sessionId: 's1', + providerId: 'openai', + modelId: 'gpt-4o', + requestSeq: 2, + endpoint: 'https://api.example.com/second', + headersJson: '{"x":"2"}', + bodyJson: '{"b":2}', + truncated: false, + createdAt: 2000 + } + ], + manifests: [makeManifestRecord(1, 'view_only_manifest')] + }) + + const wrapper = mountDialog() + + await wrapper.setProps({ messageId: 'm1' }) + await flushPromises() + + expect(wrapper.text()).toContain('https://api.example.com/second') + + const manifestOnlyButton = wrapper.findAll('button').find((btn) => btn.text().trim() === '#1') + expect(manifestOnlyButton).toBeDefined() + + await manifestOnlyButton!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('traceDialog.requestUnavailable') + expect(wrapper.text()).not.toContain('https://api.example.com/second') + + const viewTab = wrapper + .findAll('button') + .find((btn) => btn.text().trim() === 'traceDialog.tabs.view') + expect(viewTab).toBeDefined() + + await viewTab!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('view_only_manifest') + }) })