From 8d60aca582b1dd627e615cf29e474358300f28ec Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 22:34:43 +0800 Subject: [PATCH 01/17] docs(tape): specify subagent tape lineage --- .../subagent-tape-lineage/plan.md | 94 ++++++++++ .../subagent-tape-lineage/spec.md | 160 ++++++++++++++++++ .../subagent-tape-lineage/tasks.md | 13 ++ 3 files changed, 267 insertions(+) create mode 100644 docs/architecture/subagent-tape-lineage/plan.md create mode 100644 docs/architecture/subagent-tape-lineage/spec.md create mode 100644 docs/architecture/subagent-tape-lineage/tasks.md diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md new file mode 100644 index 000000000..c8b87b6b2 --- /dev/null +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -0,0 +1,94 @@ +# Subagent Tape Lineage - Implementation Plan + +## Phase 1: Specify the boundary + +- Record the distinction between true fork merge and durable subagent link. +- Freeze the event, outcome, authorization, head-cutoff, compatibility, and read-only view + contracts before changing production code. +- Keep the current session relationship as the topology and authorization source of truth. + +## Phase 2: Harden true fork merge + +- Persist the exact parent head in `fork/start`. +- Freeze the fork head when merge begins and select only entries at or below it. +- Exclude `session/start` and `fork/start` from copied delta entries. +- Add a narrow Tape-store transaction operation so copied delta and `fork/merge` receipt commit or + roll back together. +- Use stable merge provenance to return an existing receipt on retry. +- Cover empty forks, injected append failure, retry, and concurrent tail append boundaries with + native SQLite tests. + +## Phase 3: Model production subagents as links + +- Add typed `SubagentTapeLinkInput`, `SubagentTapeLinkOutcome`, and `SubagentTapeLinkReceipt` + contracts at the shared runtime boundary. +- Replace `mergeSubagentTape` and `discardSubagentTape` ports with one `linkSubagentTape` port across + DeepChat, direct ACP, session assignment, presenter, and tool runtime adapters. +- Append `subagent/tape_linked` with stable task provenance and a frozen child head/count. +- Update orchestration finalization so completed, error, and cancelled tasks all link after child + settlement, while absent/failed capability leaves the task retryable. +- Preserve legacy event reads without producing new legacy external fork records. + +## Phase 4: Add authorized linked Tape views + +- Introduce `AgentTapeViewScope` and a resolver that returns the current source and/or finalized + direct-child sources with immutable cutoffs. +- Reuse persisted session ownership for authorization and Tape events for finalized snapshots. +- Extend projection/FTS reads to query a bounded authorized source set, enforce per-source cutoffs, + and apply one global result limit. +- Add source `sessionId` to search results. +- Extend context reads with `sourceSessionId`, reject mixed or unauthorized sources, and keep one + context window within one Tape. +- Return explicit diagnostics for deleted or unavailable linked Tapes without triggering bootstrap, + backfill, or repair writes. + +## Phase 5: Expose cross-Tape recall through existing tools + +- Add optional `scope` to `tape_search` and optional `sourceSessionId` to `tape_context`. +- Keep omitted fields compatible with current-session-only recall. +- Carry the types through tool manager, runtime session routes, DeepChat backend, and direct ACP + backend without adding another model tool. +- Add tool-schema, route, ownership, ACP, global-limit, and parent-effective-view non-interference + tests. + +## Phase 6: Validate and finalize contracts + +- Review the complete branch diff against hidden writes, compatibility, transactionality, edge + cases, performance, authorization, naming, test depth, and future maintenance cost. +- Run focused Tape/orchestrator/tool tests, native SQLite tests, main tests, typecheck, i18n, lint, + format, and format check. +- Update the retained Tape baseline, runtime Tape contract, and this task ledger only for behavior + proven by the implementation and validation. +- Do not run the full build in this slice because it refreshes unrelated provider and ACP registry + artifacts. + +## Commit Strategy + +1. `docs(tape): specify subagent tape lineage` +2. `fix(tape): make fork merge atomic` +3. `refactor(tape): model subagents as tape links` +4. `feat(tape): add linked tape views` +5. `feat(tools): add cross-tape recall` +6. `docs(tape): finalize subagent tape lineage` + +If cumulative review finds an implementation defect not cleanly owned by the final documentation +commit, add `fix(tape): address lineage review findings` without rewriting prior commits. + +## Review Gate + +Before every commit: + +1. Inspect status, full unstaged diff/stat/check, and run the smallest sufficient validation. +2. Review P0 through P3 for hidden side effects, compatibility, boundaries, performance, security, + semantic naming, test gaps, and maintenance cost. +3. Fix every in-scope actionable finding and repeat the review. +4. Stage explicit task paths only; inspect full staged diff/check and repeat the same severity review. +5. Commit only when staged changes contain no unrelated file and no actionable P0-P3 finding. + +## Rollback and Compatibility + +- The change adds no database schema migration; rollback leaves append-only link events readable as + inert unknown events by older code. +- Current-only tool calls and true fork event readers remain compatible. +- New code keeps a legacy reader for old external `fork/merge` events. +- No child Tape is deleted or copied as part of production subagent finalization. diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md new file mode 100644 index 000000000..4d8a002fc --- /dev/null +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -0,0 +1,160 @@ +# Subagent Tape Lineage - Specification + +> Status: **planned** + +## Problem + +DeepChat uses two different lineage models under the word "merge": + +1. `DeepChatTapeService.mergeFork()` copies a true fork's selected delta into its parent Tape. +2. Production subagents run in independent sessions, but `mergeSubagentTape()` only appends a + `fork/merge` event containing child metadata and a textual result summary. + +The production operation is useful as an audit link, but it is not a Tape merge: child entries do +not enter the parent Tape, the parent effective view remains unchanged, and current Tape recall can +only query one session. Keeping the merge name makes the persistence contract and the model-facing +capabilities disagree. + +This specification supersedes the merge/discard wording in the implemented +`subagent-run-guardrails` acceptance criteria. It preserves that specification's cancellation +settlement and retry ordering while replacing the misleading persistence operation. + +## Decision + +Production subagent sessions remain independent Tapes. Their parent records a typed, immutable link +at task finalization, and Tape recall can explicitly resolve authorized linked-child views. + +True forks retain copy-on-merge semantics and gain an atomic, idempotent merge boundary. The two +concepts are deliberately separate: + +| Concept | Storage model | Parent effective view | Finalization record | +| --- | --- | --- | --- | +| True fork | Temporary branch Tape | Receives selected delta on merge | `fork/merge` | +| Production subagent | Durable child session/Tape | Never receives copied child entries | `subagent/tape_linked` | + +## Invariants + +### Production subagent links + +1. A production child is identified by its durable session relationship: + `new_sessions.parent_session_id = parentSessionId` and `session_kind = subagent`. +2. The relationship table is the authorization source of truth. A link event is a finalized + snapshot, not a second mutable ownership graph. +3. `linkSubagentTape()` accepts a closed outcome union: `completed | error | cancelled`. +4. A successful link appends exactly one `subagent/tape_linked` event to the parent and returns a + receipt containing the parent link entry, child head cutoff, child entry count, and outcome. +5. The event references the child through `source_type = subagent`, `source_id = childSessionId`, + and `source_seq = childHead`. It does not copy child payloads or raw provider data. +6. Missing link capability or failed persistence is not finalization. The orchestrator must leave + `tapeFinalized = false` so a retry remains possible and the failure is observable. +7. Retrying the same task outcome is idempotent. It must return the existing receipt rather than + append a second link. +8. Completed, errored, and cancelled tasks all retain their child Tape and use the same link + operation. Outcome describes lifecycle state; it does not imply merge or deletion. + +### True fork merge + +1. `createFork()` records the exact parent head from which the fork was created. +2. `mergeFork()` freezes the fork head before selecting the delta. +3. Branch-local bootstrap/control anchors (`session/start` and `fork/start`) are not copied. +4. All selected delta entries and the parent `fork/merge` receipt are appended in one SQLite + transaction. A failure leaves neither partial delta nor a receipt. +5. A retry after a committed merge is idempotent and returns the existing receipt. +6. Entries appended after the frozen fork head are outside that merge and cannot leak into it. + +## Cross-Tape View Contract + +### Scope + +```ts +type AgentTapeViewScope = 'current' | 'linked_subagents' | 'current_and_linked' +``` + +- `current` remains the default for compatibility. +- `linked_subagents` searches finalized, directly linked child Tapes only. +- `current_and_linked` searches the current Tape and those linked children. +- A global result limit is applied after combining sources; it is not multiplied per child. + +### Authorization and cutoff + +A linked source is readable only when all of the following hold: + +1. The requested parent is the current persisted session. +2. The child still exists and is a direct child owned by that parent. +3. A valid finalized `subagent/tape_linked` record, regardless of task outcome, or a compatible + legacy record authorizes the view. +4. Reads stop at the immutable head recorded by that link. + +Arbitrary session IDs supplied by a model or caller never authorize access. Grandchildren are not +traversed. Multiple link events for one child resolve independently by finalized snapshot and are +deduplicated by source session and cutoff where appropriate. + +### Search and context + +- Search results include `sessionId` so entry IDs remain unambiguous across Tapes. +- Context expansion accepts optional `sourceSessionId`. When omitted, it reads the current session. +- One context request expands one source Tape only. Entry windows never cross a Tape boundary. +- A non-current source must resolve through the parent's authorized direct-child links and must + stay within the link cutoff. +- A deleted child produces an explicit `linked_tape_unavailable` result/error. It does not silently + fall back to the parent or return partial data from another source. +- Search and context remain read-only: no bootstrap, backfill, projection repair, memory ingestion, + or event publication is triggered by a linked-source read. + +## Compatibility + +1. Existing callers that omit scope retain current-session-only behavior and result ordering. +2. Existing `tape_search` and `tape_context` remain the only model tools; no third tool or renderer + surface is added. +3. Old external `fork/merge` records are read as completed child links when their child session + relationship is valid. Their positive `referencedEntryCount` is used as the legacy frozen + cutoff because those records did not store an exact child head. +4. Old external `fork/discard` records remain audit-only and never authorize a linked view. +5. Existing true `fork/merge` records keep their original meaning. +6. No database schema migration is required. The existing Tape source fields and session + relationship are reused. + +## Security and Privacy + +- Parent-child ownership is checked from persisted session metadata for every cross-Tape read. +- Link payloads contain identifiers, counts, lifecycle outcome, task metadata, and bounded result + summary only; they do not duplicate raw child entries, prompts, traces, headers, or credentials. +- Search projection rows are scoped by the authorized source set and cutoff before results are + returned. +- Context reads reject mixed-source entry IDs and source IDs not derived from an authorized link. + +## Performance Constraints + +- Resolve direct linked sources with bounded indexed queries; do not scan every session. +- Do not materialize complete child Tapes to search them. +- Apply source cutoff in SQL/projection reads, and enforce one global search limit. +- Avoid N+1 full-session hydration and linked-source bootstrap/backfill. +- Preserve existing FTS relevance ordering with deterministic Tape order tie-breaking across + sources. + +## Non-goals + +- Copying production child entries into the parent Tape. +- Including linked child entries in the parent provider context or effective view. +- Recursive descendant search. +- A generic import/materialization namespace for external Tapes. +- UI for lineage browsing or child Tape recovery. +- Changing current session deletion/cascade policy. +- Syncing a GitHub issue as part of this architecture slice. + +## Acceptance Criteria + +- Production subagent finalization uses link terminology and typed outcomes end to end. +- A missing or failed link write remains retryable and is never reported as finalized. +- True fork merge is atomic, idempotent, head-bounded, and excludes branch-local control anchors. +- Current-only recall remains byte-for-byte compatible for callers that omit new fields. +- Authorized linked recall returns source session IDs and never reads beyond a frozen head. +- Sibling, grandchild, unrelated, deleted, and malformed legacy sources cannot leak data. +- Cross-Tape search respects one global limit and context windows remain within one Tape. +- DeepChat and direct ACP subagent routes share the same link and recall contract. +- Focused persistence, failure-recovery, authorization, compatibility, and non-interference tests + pass. + +## Open Questions + +None. diff --git a/docs/architecture/subagent-tape-lineage/tasks.md b/docs/architecture/subagent-tape-lineage/tasks.md new file mode 100644 index 000000000..cd52634ff --- /dev/null +++ b/docs/architecture/subagent-tape-lineage/tasks.md @@ -0,0 +1,13 @@ +# Subagent Tape Lineage - Tasks + +- [x] T1: Specify true fork merge versus production subagent link semantics. +- [x] T2: Specify immutable child-head cutoff, direct-child authorization, and legacy compatibility. +- [ ] T3: Make true fork merge atomic, idempotent, and head-bounded. +- [ ] T4: Replace production merge/discard ports with typed subagent Tape links. +- [ ] T5: Add authorized linked-source resolution and cross-Tape search/context reads. +- [ ] T6: Extend the existing Tape tools and runtime routes without changing default behavior. +- [ ] T7: Add persistence, rollback, retry, lifecycle, authorization, performance-boundary, and + non-interference tests. +- [ ] T8: Complete cumulative severity review and resolve all actionable findings. +- [ ] T9: Run focused and full validation required by this architecture slice. +- [ ] T10: Update retained Tape contracts with only validated behavior. From 33523be4ae719fa920216e74248c87e3e3c08861 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 22:40:17 +0800 Subject: [PATCH 02/17] fix(tape): make fork merge atomic --- .../agentRuntimePresenter/tapeService.ts | 112 ++++++---- .../tables/deepchatTapeEntries.ts | 15 ++ .../agentRuntimePresenter/tapeService.test.ts | 196 +++++++++++++++++- 3 files changed, 286 insertions(+), 37 deletions(-) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index f43d652f0..acd03e31e 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -89,6 +89,7 @@ export type TapeForkHandle = { parentSessionId: string forkId: string forkSessionId: string + parentHeadEntryId: number } export type TapeViewManifestSourceMaps = { @@ -1526,9 +1527,10 @@ export class DeepChatTapeService implements Pick const forkIdValue = forkId.trim() || nanoid() const forkSessionIdValue = forkSessionId(parentSessionId, forkIdValue) + const parentHeadEntryId = table.getMaxEntryId(parentSessionId) table.ensureBootstrapAnchor(forkSessionIdValue) const parentAnchor = table.getLatestAnchor(parentSessionId) - table.appendAnchor({ + const forkStart = table.appendAnchor({ sessionId: forkSessionIdValue, name: 'fork/start', source: { @@ -1539,15 +1541,30 @@ export class DeepChatTapeService implements Pick provenanceKey: `fork:${parentSessionId}:${forkIdValue}:start`, state: { parentSessionId, + parentHeadEntryId, parentLastAnchorEntryId: parentAnchor?.entry_id ?? null, parentLastAnchorName: parentAnchor?.name ?? null }, idempotent: true }) + const forkStartPayload = parseJsonObject(forkStart.payload_json) + const persistedState = + forkStartPayload.state && + typeof forkStartPayload.state === 'object' && + !Array.isArray(forkStartPayload.state) + ? (forkStartPayload.state as Record) + : {} + const persistedParentHeadEntryId = persistedState.parentHeadEntryId return { parentSessionId, forkId: forkIdValue, - forkSessionId: forkSessionIdValue + forkSessionId: forkSessionIdValue, + parentHeadEntryId: + typeof persistedParentHeadEntryId === 'number' && + Number.isSafeInteger(persistedParentHeadEntryId) && + persistedParentHeadEntryId >= 0 + ? persistedParentHeadEntryId + : parentHeadEntryId } } @@ -1569,53 +1586,76 @@ export class DeepChatTapeService implements Pick } const forkSessionIdValue = forkSessionId(parentSessionId, forkId) - const forkEntries = table - .getBySession(forkSessionIdValue) - .filter((entry) => !(entry.kind === 'anchor' && entry.name === 'session/start')) + const mergeProvenanceKey = `fork:${parentSessionId}:${forkId}:merge:event` + + return table.runInTransaction(() => { + const existingReceipt = table.getByProvenanceKey(parentSessionId, mergeProvenanceKey) + if (existingReceipt) { + const payload = parseJsonObject(existingReceipt.payload_json) + const data = + payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) + ? (payload.data as Record) + : {} + const mergedCount = data.mergedCount + return typeof mergedCount === 'number' && Number.isSafeInteger(mergedCount) + ? Math.max(0, mergedCount) + : 0 + } + + const forkHeadEntryId = table.getMaxEntryId(forkSessionIdValue) + const forkEntries = table + .getBySessionUpToEntryId(forkSessionIdValue, forkHeadEntryId) + .filter( + (entry) => + !( + entry.kind === 'anchor' && + (entry.name === 'session/start' || entry.name === 'fork/start') + ) + ) + + for (const entry of forkEntries) { + table.append({ + sessionId: parentSessionId, + kind: entry.kind, + name: entry.name, + source: { + type: 'fork', + id: forkId, + seq: entry.entry_id + }, + provenanceKey: `fork:${parentSessionId}:${forkId}:merge:${entry.entry_id}`, + payload: parseJsonObject(entry.payload_json), + meta: { + ...parseJsonObject(entry.meta_json), + forkId, + forkSessionId: forkSessionIdValue, + mergedFromEntryId: entry.entry_id + }, + createdAt: entry.created_at, + idempotent: true + }) + } - let mergedCount = 0 - for (const entry of forkEntries) { - table.append({ + table.appendEvent({ sessionId: parentSessionId, - kind: entry.kind, - name: entry.name, + name: 'fork/merge', source: { type: 'fork', id: forkId, - seq: entry.entry_id + seq: 0 }, - provenanceKey: `fork:${parentSessionId}:${forkId}:merge:${entry.entry_id}`, - payload: parseJsonObject(entry.payload_json), - meta: { - ...parseJsonObject(entry.meta_json), + provenanceKey: mergeProvenanceKey, + data: { forkId, forkSessionId: forkSessionIdValue, - mergedFromEntryId: entry.entry_id + forkHeadEntryId, + mergedCount: forkEntries.length }, - createdAt: entry.created_at, idempotent: true }) - mergedCount += 1 - } - table.appendEvent({ - sessionId: parentSessionId, - name: 'fork/merge', - source: { - type: 'fork', - id: forkId, - seq: 0 - }, - provenanceKey: `fork:${parentSessionId}:${forkId}:merge:event`, - data: { - forkId, - forkSessionId: forkSessionIdValue, - mergedCount - }, - idempotent: true + return forkEntries.length }) - - return mergedCount } discardFork(parentSessionId: string, forkId: string): void { diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts index 9007db330..e5d1a0616 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts @@ -153,6 +153,10 @@ export class DeepChatTapeEntriesTable extends BaseTable { return 0 } + runInTransaction(operation: () => T): T { + return this.db.transaction(operation)() + } + append(input: DeepChatTapeAppendInput): DeepChatTapeEntryRow { const append = this.db.transaction(() => { const provenanceKey = buildProvenanceKey(input) @@ -333,6 +337,17 @@ export class DeepChatTapeEntriesTable extends BaseTable { .all(sessionId) as DeepChatTapeEntryRow[] } + getBySessionUpToEntryId(sessionId: string, maxEntryId: number): DeepChatTapeEntryRow[] { + return this.db + .prepare( + `SELECT * + FROM deepchat_tape_entries + WHERE session_id = ? AND entry_id <= ? + ORDER BY entry_id ASC` + ) + .all(sessionId, maxEntryId) as DeepChatTapeEntryRow[] + } + listMemoryViewManifestAnchorsBySessions( sessionIds: string[], optionsOrLimit: number | { limit?: number; messageId?: string } = 100 diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index d38899dd5..b4fd45712 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -131,9 +131,21 @@ function createTapeTableMock() { payload: { name: input.name, data: input.data } }) ), + runInTransaction: vi.fn((operation: () => unknown) => { + const snapshot = entries.map((entry) => ({ ...entry })) + try { + return operation() + } catch (error) { + entries.splice(0, entries.length, ...snapshot) + throw error + } + }), getBySession: vi.fn((sessionId: string) => entries.filter((entry) => entry.session_id === sessionId) ), + getBySessionUpToEntryId: vi.fn((sessionId: string, maxEntryId: number) => + entries.filter((entry) => entry.session_id === sessionId && entry.entry_id <= maxEntryId) + ), getMaxEntryId: vi.fn((sessionId: string) => Math.max( 0, @@ -3861,13 +3873,16 @@ describe('DeepChatTapeService', () => { const mergedCount = service.mergeFork('s1', 'fork-1') - expect(mergedCount).toBeGreaterThan(0) + expect(mergedCount).toBe(1) expect( entries.some((entry) => entry.session_id === 's1' && entry.name === 'message/user') ).toBe(true) expect(entries.some((entry) => entry.session_id === 's1' && entry.name === 'fork/merge')).toBe( true ) + expect(entries.some((entry) => entry.session_id === 's1' && entry.name === 'fork/start')).toBe( + false + ) const discardFork = service.createFork('s1', 'fork-2') service.appendForkMessageRecord(discardFork, createRecord({ id: 'fu2', sessionId: 'ignored' })) @@ -3879,6 +3894,185 @@ describe('DeepChatTapeService', () => { ).toBe(true) }) + it('records exact fork heads and keeps retries bounded to the first merge', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + + table.ensureBootstrapAnchor('parent') + table.appendEvent({ sessionId: 'parent', name: 'parent/tail', data: { value: 1 } }) + const fork = service.createFork('parent', 'bounded') + table.appendEvent({ sessionId: 'parent', name: 'parent/later', data: { value: 2 } }) + const retriedFork = service.createFork('parent', 'bounded') + service.appendForkMessageRecord(fork, createRecord({ id: 'first', sessionId: 'ignored' })) + + const forkStart = entries.find( + (entry) => entry.session_id === fork.forkSessionId && entry.name === 'fork/start' + ) + expect(fork.parentHeadEntryId).toBe(2) + expect(retriedFork.parentHeadEntryId).toBe(2) + expect(JSON.parse(forkStart.payload_json).state.parentHeadEntryId).toBe(2) + + const getBoundedEntries = table.getBySessionUpToEntryId.getMockImplementation()! + table.getBySessionUpToEntryId.mockImplementationOnce( + (sessionId: string, maxEntryId: number) => { + table.appendEvent({ + sessionId: fork.forkSessionId, + name: 'fork/late-tail', + data: { value: 2 } + }) + return getBoundedEntries(sessionId, maxEntryId) + } + ) + + expect(service.mergeFork('parent', 'bounded')).toBe(1) + expect(service.mergeFork('parent', 'bounded')).toBe(1) + + const parentEntries = entries.filter((entry) => entry.session_id === 'parent') + expect(parentEntries.filter((entry) => entry.source_type === 'fork')).toHaveLength(2) + expect(parentEntries.some((entry) => entry.name === 'fork/late-tail')).toBe(false) + const receipt = parentEntries.find((entry) => entry.name === 'fork/merge')! + expect(JSON.parse(receipt.payload_json).data).toMatchObject({ + forkHeadEntryId: 3, + mergedCount: 1 + }) + }) + + it('rolls back mocked fork merge writes when a copied entry fails', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + const fork = service.createFork('parent', 'mock-atomic') + service.appendForkMessageRecord( + fork, + createRecord({ id: 'first', orderSeq: 1, sessionId: 'ignored' }) + ) + service.appendForkMessageRecord( + fork, + createRecord({ id: 'second', orderSeq: 2, sessionId: 'ignored' }) + ) + + const append = table.append.getMockImplementation()! + let copiedEntryCount = 0 + table.append.mockImplementation((input: any) => { + if (input.sessionId === 'parent' && input.source?.type === 'fork') { + copiedEntryCount += 1 + if (copiedEntryCount === 2) { + throw new Error('injected merge failure') + } + } + return append(input) + }) + + expect(() => service.mergeFork('parent', 'mock-atomic')).toThrow('injected merge failure') + expect( + entries.filter( + (entry) => + entry.session_id === 'parent' && + (entry.source_type === 'fork' || entry.name === 'fork/merge') + ) + ).toEqual([]) + }) + + it('rolls back copied fork entries when the merge receipt fails', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + const fork = service.createFork('parent', 'receipt-failure') + service.appendForkMessageRecord(fork, createRecord({ id: 'first', sessionId: 'ignored' })) + + const appendEvent = table.appendEvent.getMockImplementation()! + table.appendEvent.mockImplementation((input: any) => { + if (input.sessionId === 'parent' && input.name === 'fork/merge') { + throw new Error('injected receipt failure') + } + return appendEvent(input) + }) + + expect(() => service.mergeFork('parent', 'receipt-failure')).toThrow('injected receipt failure') + expect( + entries.filter( + (entry) => + entry.session_id === 'parent' && + (entry.source_type === 'fork' || entry.name === 'fork/merge') + ) + ).toEqual([]) + }) + + it('commits one idempotent receipt for an empty fork', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + service.createFork('parent', 'empty') + + expect(service.mergeFork('parent', 'empty')).toBe(0) + expect(service.mergeFork('parent', 'empty')).toBe(0) + const parentEntries = entries.filter((entry) => entry.session_id === 'parent') + expect(parentEntries).toHaveLength(1) + expect(parentEntries[0].name).toBe('fork/merge') + expect(JSON.parse(parentEntries[0].payload_json).data).toMatchObject({ + forkHeadEntryId: 2, + mergedCount: 0 + }) + }) + + itIfSqlite('rolls back copied fork entries and the receipt when merge fails', () => { + const db = new DatabaseCtor(':memory:') + const table = new DeepChatTapeEntriesTable(db) + table.createTable() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + + table.ensureBootstrapAnchor('parent') + const fork = service.createFork('parent', 'atomic') + service.appendForkMessageRecord( + fork, + createRecord({ id: 'first', orderSeq: 1, sessionId: 'ignored' }) + ) + service.appendForkMessageRecord( + fork, + createRecord({ id: 'second', orderSeq: 2, sessionId: 'ignored' }) + ) + + const append = table.append.bind(table) + let copiedEntryCount = 0 + const appendSpy = vi.spyOn(table, 'append').mockImplementation((input) => { + if (input.sessionId === 'parent' && input.source?.type === 'fork') { + copiedEntryCount += 1 + if (copiedEntryCount === 2) { + throw new Error('injected merge failure') + } + } + return append(input) + }) + + expect(() => service.mergeFork('parent', 'atomic')).toThrow('injected merge failure') + expect( + table + .getBySession('parent') + .filter((entry) => entry.source_type === 'fork' || entry.name === 'fork/merge') + ).toEqual([]) + + appendSpy.mockRestore() + expect(service.mergeFork('parent', 'atomic')).toBe(2) + expect(service.mergeFork('parent', 'atomic')).toBe(2) + expect( + table.getBySession('parent').filter((entry) => entry.source_type === 'fork') + ).toHaveLength(3) + + db.close() + }) + it('cleans fork search projection on discard without blocking the discard event', () => { const { table, entries } = createTapeTableMock() const projectionTable = { From f90ce09354f6334fbf379045047c2d4303048f08 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 22:54:11 +0800 Subject: [PATCH 03/17] refactor(tape): model subagents as tape links --- .../agent/manager/deepChatAgentBackend.ts | 18 +- .../agent/manager/directAcpAgentBackend.ts | 7 +- src/main/agent/manager/sessionHandles.ts | 13 +- src/main/agent/shared/agentSharedData.ts | 15 +- .../presenter/agentRuntimePresenter/index.ts | 35 +-- .../agentRuntimePresenter/tapeService.ts | 196 +++++++++++---- src/main/presenter/index.ts | 16 +- .../agentAssignmentCoordinator.ts | 40 +-- .../presenter/sessionApplication/ports.ts | 13 +- .../tables/deepchatTapeEntries.ts | 1 + .../agentTools/subagentOrchestratorTool.ts | 41 +++- .../presenter/toolPresenter/runtimePorts.ts | 15 +- src/shared/types/agent-interface.d.ts | 24 ++ test/main/agent/manager/agentManager.test.ts | 34 ++- .../manager/deepChatAgentBackend.test.ts | 29 ++- .../manager/directAcpAgentBackend.test.ts | 25 +- .../agentRuntimePresenter/tapeService.test.ts | 64 +++-- .../agentAssignmentCoordinator.test.ts | 53 +++- .../sessionApplication.integration.test.ts | 57 +++-- .../subagentOrchestratorTool.test.ts | 230 +++++++++++------- 20 files changed, 591 insertions(+), 335 deletions(-) diff --git a/src/main/agent/manager/deepChatAgentBackend.ts b/src/main/agent/manager/deepChatAgentBackend.ts index 93ae995a0..3fca65e2f 100644 --- a/src/main/agent/manager/deepChatAgentBackend.ts +++ b/src/main/agent/manager/deepChatAgentBackend.ts @@ -11,6 +11,8 @@ import type { SessionAgentContextUpdate, SessionCompactionState, SessionGenerationSettings, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt, ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' @@ -83,16 +85,7 @@ export interface DeepChatAgentBackendPort { response: ToolInteractionResponse ): Promise hasMessages(sessionId: AppSessionId): Promise - mergeSubagentTape( - parentSessionId: AppSessionId, - childSessionId: AppSessionId, - meta?: Record - ): Promise - discardSubagentTape( - parentSessionId: AppSessionId, - childSessionId: AppSessionId, - meta?: Record - ): Promise + linkSubagentTape(input: SubagentTapeLinkInput): Promise getActiveGeneration(sessionId: AppSessionId): AgentActiveGeneration | null cancelGenerationByEventId(sessionId: AppSessionId, eventId: string): Promise setSessionAgentContext(sessionId: AppSessionId, config: SessionAgentContextUpdate): Promise @@ -130,10 +123,7 @@ export function createDeepChatAgentBackend( listPendingInputs: (sessionId) => port.listPendingInputs(sessionId) } const subagent: AgentSubagentFacet = { - mergeTape: (parentSessionId, childSessionId, meta) => - port.mergeSubagentTape(parentSessionId, childSessionId, meta), - discardTape: (parentSessionId, childSessionId, meta) => - port.discardSubagentTape(parentSessionId, childSessionId, meta) + linkTape: (input) => port.linkSubagentTape(input) } const generationControl: AgentGenerationControlFacet = { getActiveGeneration: (sessionId) => port.getActiveGeneration(sessionId), diff --git a/src/main/agent/manager/directAcpAgentBackend.ts b/src/main/agent/manager/directAcpAgentBackend.ts index 732c329f5..707a56446 100644 --- a/src/main/agent/manager/directAcpAgentBackend.ts +++ b/src/main/agent/manager/directAcpAgentBackend.ts @@ -22,7 +22,7 @@ export interface DirectAcpAgentBackendOptions { runtime: AcpAgentRuntime sessionState: AgentSessionStatePort transcript: Pick - tape: Pick + tape: Pick deleteDurableSession(sessionId: AppSessionId): Promise resolveInput( sessionId: AppSessionId, @@ -257,10 +257,7 @@ export const createDirectAcpAgentBackend = ( listPendingInputs: async (sessionId) => runtime.listPendingInputs(sessionId) }, subagent: { - mergeTape: (parentSessionId, childSessionId, meta) => - tape.mergeSubagentTape(parentSessionId, childSessionId, meta), - discardTape: (parentSessionId, childSessionId, meta) => - tape.discardSubagentTape(parentSessionId, childSessionId, meta) + linkTape: (input) => tape.linkSubagentTape(input) }, generationControl: { getActiveGeneration: (sessionId) => diff --git a/src/main/agent/manager/sessionHandles.ts b/src/main/agent/manager/sessionHandles.ts index fc69fdfd6..4dc3c7e35 100644 --- a/src/main/agent/manager/sessionHandles.ts +++ b/src/main/agent/manager/sessionHandles.ts @@ -8,6 +8,8 @@ import type { SessionAgentContextUpdate, SessionCompactionState, SessionGenerationSettings, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt, ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' @@ -109,16 +111,7 @@ export interface DeepChatTransferTargetFacet { } export interface AgentSubagentFacet { - mergeTape( - parentSessionId: AppSessionId, - childSessionId: AppSessionId, - meta?: Record - ): Promise - discardTape( - parentSessionId: AppSessionId, - childSessionId: AppSessionId, - meta?: Record - ): Promise + linkTape(input: SubagentTapeLinkInput): Promise } export interface AgentActiveGeneration { diff --git a/src/main/agent/shared/agentSharedData.ts b/src/main/agent/shared/agentSharedData.ts index 58b421e34..adcee6d6f 100644 --- a/src/main/agent/shared/agentSharedData.ts +++ b/src/main/agent/shared/agentSharedData.ts @@ -14,7 +14,9 @@ import type { PermissionMode, SendMessageInput, SessionAgentContextUpdate, - SessionGenerationSettings + SessionGenerationSettings, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt } from '@shared/types/agent-interface' import type { DeepChatTapeViewManifestRecord } from '@shared/types/tape-view-manifest' import type { @@ -97,16 +99,7 @@ export interface AgentTapePort { messageId: string, options?: DeepChatTapeReplayExportOptions ): Promise - mergeSubagentTape( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise - discardSubagentTape( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise + linkSubagentTape(input: SubagentTapeLinkInput): Promise } export interface AgentSharedDataPorts { diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 1e5703aa8..622797e0d 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -22,6 +22,8 @@ import type { SessionCompactionState, SessionAgentContextUpdate, SessionGenerationSettings, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt, ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' @@ -80,7 +82,11 @@ import { } from './compactionService' import { reviewAutoApproveToolPermission } from './toolPermissionReviewer' import { buildTerminalErrorBlocks, DeepChatMessageStore } from './messageStore' -import { DeepChatTapeService, normalizeTapeHandoffState } from './tapeService' +import { + DeepChatTapeService, + normalizeSubagentTapeLinkInput, + normalizeTapeHandoffState +} from './tapeService' import { PendingInputCoordinator } from '@/agent/deepchat/pending/pendingInputCoordinator' import { DeepChatPendingInputStore } from '@/agent/deepchat/pending/pendingInputStore' import { DeepChatSessionStore, type SessionSummaryState } from './sessionStore' @@ -1628,28 +1634,11 @@ export class AgentRuntimePresenter { return this.tapeService.exportReplaySlice(sessionId, messageId, options) } - async mergeSubagentTape( - parentSessionId: string, - childSessionId: string, - meta: Record = {} - ): Promise { - this.tapeService.ensureSessionTapeReady(parentSessionId, this.messageStore) - this.tapeService.ensureSessionTapeReady(childSessionId, this.messageStore) - this.tapeService.recordExternalForkMerge(parentSessionId, childSessionId, childSessionId, meta) - } - - async discardSubagentTape( - parentSessionId: string, - childSessionId: string, - meta: Record = {} - ): Promise { - this.tapeService.ensureSessionTapeReady(parentSessionId, this.messageStore) - this.tapeService.recordExternalForkDiscard( - parentSessionId, - childSessionId, - childSessionId, - meta - ) + async linkSubagentTape(input: SubagentTapeLinkInput): Promise { + const normalized = normalizeSubagentTapeLinkInput(input) + this.tapeService.ensureSessionTapeReady(normalized.parentSessionId, this.messageStore) + this.tapeService.ensureSessionTapeReady(normalized.childSessionId, this.messageStore) + return this.tapeService.linkSubagentTape(normalized) } async listMessagesPage( diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index acd03e31e..2646f11bc 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -10,7 +10,10 @@ import type { AgentTapeContextResult, AgentTapeHandoffState, AgentTapeSearchOptions, - ChatMessageRecord + ChatMessageRecord, + SubagentTapeLinkInput, + SubagentTapeLinkOutcome, + SubagentTapeLinkReceipt } from '@shared/types/agent-interface' import type { DeepChatTapeViewExcludedRange, @@ -139,6 +142,108 @@ function parseJsonValue(raw: string): unknown { } } +const SUBAGENT_TAPE_LINK_EVENT_NAME = 'subagent/tape_linked' +const SUBAGENT_TAPE_LINK_OUTCOMES = new Set([ + 'completed', + 'error', + 'cancelled' +]) + +export function normalizeSubagentTapeLinkInput( + input: SubagentTapeLinkInput +): SubagentTapeLinkInput { + const normalized = { + parentSessionId: input.parentSessionId.trim(), + childSessionId: input.childSessionId.trim(), + runId: input.runId.trim(), + taskId: input.taskId.trim(), + slotId: input.slotId.trim(), + taskTitle: compactText(input.taskTitle, 500), + outcome: input.outcome, + resultSummary: input.resultSummary?.trim() ? compactText(input.resultSummary, 2000) : null + } + for (const [name, value] of Object.entries(normalized)) { + if (name === 'resultSummary' || name === 'outcome') continue + if (typeof value !== 'string' || !value) { + throw new Error(`Subagent Tape link ${name} is required.`) + } + } + if (normalized.parentSessionId === normalized.childSessionId) { + throw new Error('Subagent Tape link child must differ from its parent.') + } + if (!SUBAGENT_TAPE_LINK_OUTCOMES.has(normalized.outcome)) { + throw new Error(`Invalid subagent Tape link outcome: ${String(normalized.outcome)}`) + } + return normalized +} + +function subagentTapeLinkProvenanceKey(input: SubagentTapeLinkInput): string { + const identityHash = createHash('sha256') + .update( + JSON.stringify([input.parentSessionId, input.childSessionId, input.runId, input.taskId]) + ) + .digest('hex') + return `subagent:tape-link:v1:${identityHash}` +} + +function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkReceipt { + const payload = parseJsonObject(row.payload_json) + const data = + payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) + ? (payload.data as Record) + : {} + const childSessionId = data.childSessionId + const childHeadEntryId = data.childHeadEntryId + const childEntryCount = data.childEntryCount + const outcome = data.outcome + if ( + row.kind !== 'event' || + row.name !== SUBAGENT_TAPE_LINK_EVENT_NAME || + typeof childSessionId !== 'string' || + !childSessionId || + typeof childHeadEntryId !== 'number' || + !Number.isSafeInteger(childHeadEntryId) || + childHeadEntryId < 0 || + typeof childEntryCount !== 'number' || + !Number.isSafeInteger(childEntryCount) || + childEntryCount < 0 || + typeof outcome !== 'string' || + !SUBAGENT_TAPE_LINK_OUTCOMES.has(outcome as SubagentTapeLinkOutcome) || + data.linkVersion !== 1 || + row.source_type !== 'subagent' || + row.source_id !== childSessionId || + row.source_seq !== childHeadEntryId || + childEntryCount > childHeadEntryId + ) { + throw new Error(`Stored subagent Tape link receipt is malformed: ${row.entry_id}`) + } + return { + linkEntry: { + sessionId: row.session_id, + entryId: row.entry_id + }, + childSessionId, + childHeadEntryId, + childEntryCount, + outcome: outcome as SubagentTapeLinkOutcome + } +} + +function assertSubagentTapeLinkReceiptMatchesInput( + receipt: SubagentTapeLinkReceipt, + input: SubagentTapeLinkInput +): void { + if ( + receipt.linkEntry.sessionId !== input.parentSessionId || + receipt.childSessionId !== input.childSessionId || + receipt.outcome !== input.outcome + ) { + throw new Error( + `Subagent Tape link conflicts with finalized task ${input.runId}/${input.taskId}.` + ) + } +} + function compactText(value: string, maxLength = 1000): string { const normalized = value.replace(/\s+/g, ' ').trim() if (normalized.length <= maxLength) return normalized @@ -1688,63 +1793,50 @@ export class DeepChatTapeService implements Pick }) } - recordExternalForkMerge( - parentSessionId: string, - forkSessionIdValue: string, - forkId: string, - meta: Record = {} - ): DeepChatTapeEntryRow { + linkSubagentTape(input: SubagentTapeLinkInput): SubagentTapeLinkReceipt { const table = this.table if (!table) { throw new Error('Tape table is not available.') } - const referencedEntryCount = table.countBySession(forkSessionIdValue) - return table.appendEvent({ - sessionId: parentSessionId, - name: 'fork/merge', - source: { - type: 'fork', - id: forkId, - seq: 0 - }, - provenanceKey: `fork:${parentSessionId}:${forkId}:external-merge:event`, - data: { - forkId, - forkSessionId: forkSessionIdValue, - referencedEntryCount, - ...meta - }, - idempotent: true - }) - } - - recordExternalForkDiscard( - parentSessionId: string, - forkSessionIdValue: string, - forkId: string, - meta: Record = {} - ): DeepChatTapeEntryRow { - const table = this.table - if (!table) { - throw new Error('Tape table is not available.') - } + const normalized = normalizeSubagentTapeLinkInput(input) + const provenanceKey = subagentTapeLinkProvenanceKey(normalized) + return table.runInTransaction(() => { + const existing = table.getByProvenanceKey(normalized.parentSessionId, provenanceKey) + if (existing) { + const receipt = toSubagentTapeLinkReceipt(existing) + assertSubagentTapeLinkReceiptMatchesInput(receipt, normalized) + return receipt + } - return table.appendEvent({ - sessionId: parentSessionId, - name: 'fork/discard', - source: { - type: 'fork', - id: forkId, - seq: 0 - }, - provenanceKey: `fork:${parentSessionId}:${forkId}:external-discard:event`, - data: { - forkId, - forkSessionId: forkSessionIdValue, - ...meta - }, - idempotent: true + const childHeadEntryId = table.getMaxEntryId(normalized.childSessionId) + const childEntryCount = table.countBySession(normalized.childSessionId) + const row = table.appendEvent({ + sessionId: normalized.parentSessionId, + name: SUBAGENT_TAPE_LINK_EVENT_NAME, + source: { + type: 'subagent', + id: normalized.childSessionId, + seq: childHeadEntryId + }, + provenanceKey, + data: { + linkVersion: 1, + childSessionId: normalized.childSessionId, + childHeadEntryId, + childEntryCount, + runId: normalized.runId, + taskId: normalized.taskId, + slotId: normalized.slotId, + taskTitle: normalized.taskTitle, + outcome: normalized.outcome, + resultSummary: normalized.resultSummary + }, + idempotent: true + }) + const receipt = toSubagentTapeLinkReceipt(row) + assertSubagentTapeLinkReceiptMatchesInput(receipt, normalized) + return receipt }) } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 3cb68c9b0..fe8f37440 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -400,20 +400,8 @@ export class Presenter implements IPresenter { const created = await this.sessionLifecycleCoordinator.createSubagentSession(input) return await agentToolRuntime.resolveConversationSessionInfo(created.id) }, - mergeSubagentTape: async (parentSessionId, childSessionId, meta) => { - await this.sessionAgentAssignmentCoordinator.mergeSubagentTape( - parentSessionId, - childSessionId, - meta - ) - }, - discardSubagentTape: async (parentSessionId, childSessionId, meta) => { - await this.sessionAgentAssignmentCoordinator.discardSubagentTape( - parentSessionId, - childSessionId, - meta - ) - }, + linkSubagentTape: async (input) => + await this.sessionAgentAssignmentCoordinator.linkSubagentTape(input), sendConversationMessage: async (conversationId, content) => { await this.sessionTurnCoordinator.sendMessage(conversationId, content) }, diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts index 93e23cd53..fce174b87 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts @@ -6,7 +6,9 @@ import type { PermissionMode, SessionGenerationSettings, SessionRecord, - SessionWithState + SessionWithState, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt } from '@shared/types/agent-interface' import type { AcpConfigState } from '@shared/presenter' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' @@ -38,32 +40,16 @@ export class SessionAgentAssignmentCoordinator { constructor(private readonly dependencies: SessionAgentAssignmentDependencies) {} - async mergeSubagentTape( - parentSessionId: string, - childSessionId: string, - meta: Record = {} - ): Promise { - this.requireChildSession(parentSessionId, childSessionId) - const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId)) - await resolved.facet.mergeTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta - ) - } - - async discardSubagentTape( - parentSessionId: string, - childSessionId: string, - meta: Record = {} - ): Promise { - this.requireChildSession(parentSessionId, childSessionId) - const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId)) - await resolved.facet.discardTape( - toAppSessionId(parentSessionId), - toAppSessionId(childSessionId), - meta + async linkSubagentTape(input: SubagentTapeLinkInput): Promise { + this.requireChildSession(input.parentSessionId, input.childSessionId) + const resolved = this.dependencies.runtime.resolveSubagentFacet( + toAppSessionId(input.parentSessionId) ) + return await resolved.facet.linkTape({ + ...input, + parentSessionId: toAppSessionId(input.parentSessionId), + childSessionId: toAppSessionId(input.childSessionId) + }) } async getAgentTransferImpact(agentId: string): Promise { @@ -586,7 +572,7 @@ export class SessionAgentAssignmentCoordinator private requireChildSession(parentSessionId: string, childSessionId: string): void { this.requireSession(parentSessionId) const child = this.requireSession(childSessionId) - if (child.parentSessionId !== parentSessionId) { + if (child.sessionKind !== 'subagent' || child.parentSessionId !== parentSessionId) { throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`) } } diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index a6aeb8476..0fc33e007 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -37,6 +37,8 @@ import type { SessionRecord, SessionWithState, SessionMetadata, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt, ToolInteractionResponse, ToolInteractionResult } from '@shared/types/agent-interface' @@ -495,16 +497,7 @@ export interface SessionLifecyclePort { } export interface SessionAgentAssignmentPort { - mergeSubagentTape( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise - discardSubagentTape( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise + linkSubagentTape(input: SubagentTapeLinkInput): Promise getAgentTransferImpact(agentId: string): Promise moveAgentSessions( fromAgentId: string, diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts index e5d1a0616..11a3a5280 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts @@ -14,6 +14,7 @@ export type DeepChatTapeSourceType = | 'migration' | 'summary' | 'fork' + | 'subagent' export interface DeepChatTapeEntryRow { session_id: string diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 8830f819f..465c0e56e 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid' import { z } from 'zod' import type { MCPToolDefinition } from '@shared/presenter' -import type { DeepChatSubagentSlot } from '@shared/types/agent-interface' +import type { DeepChatSubagentSlot, SubagentTapeLinkOutcome } from '@shared/types/agent-interface' import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' import type { AgentToolCallResult } from './agentToolManager' import type { AgentToolRuntimePort, ConversationSessionInfo } from '../runtimePorts' @@ -293,7 +293,7 @@ const buildHandoffMessage = (params: { ].join('\n') } -const isTerminalStatus = (status: SubagentTerminalStatus): boolean => +const isTerminalStatus = (status: SubagentTerminalStatus): status is SubagentTapeLinkOutcome => status === 'completed' || status === 'error' || status === 'cancelled' export class SubagentOrchestratorTool { @@ -552,6 +552,7 @@ export class SubagentOrchestratorTool { if ( !task.sessionId || task.tapeFinalized || + !isTerminalStatus(task.status) || (task.status === 'cancelled' && (!task.handoffSettled || (task.cancellationPromise && !task.cancellationSettled))) ) { @@ -564,27 +565,45 @@ export class SubagentOrchestratorTool { } const childSessionId = task.sessionId - const meta = { + const linkSubagentTape = this.runtimePort.linkSubagentTape + if (!linkSubagentTape) { + task.tapeFinalizeError = 'Subagent Tape link capability is unavailable.' + return + } + + const input = { + parentSessionId, + childSessionId, runId, taskId: task.taskId, slotId: task.slotId, - title: task.title, - status: task.status, + taskTitle: task.title, + outcome: task.status, resultSummary: task.resultSummary ?? null } task.tapeFinalizePromise = (async () => { try { - if (task.status === 'completed') { - await this.runtimePort.mergeSubagentTape?.(parentSessionId, childSessionId, meta) - } else { - await this.runtimePort.discardSubagentTape?.(parentSessionId, childSessionId, meta) + const receipt = await linkSubagentTape(input) + if ( + receipt.linkEntry.sessionId !== parentSessionId || + !Number.isSafeInteger(receipt.linkEntry.entryId) || + receipt.linkEntry.entryId <= 0 || + receipt.childSessionId !== childSessionId || + !Number.isSafeInteger(receipt.childHeadEntryId) || + receipt.childHeadEntryId < 0 || + !Number.isSafeInteger(receipt.childEntryCount) || + receipt.childEntryCount < 0 || + receipt.childEntryCount > receipt.childHeadEntryId || + receipt.outcome !== input.outcome + ) { + throw new Error('Subagent Tape link receipt does not match the finalized task.') } task.tapeFinalized = true task.tapeFinalizeError = undefined } catch (error) { task.tapeFinalizeError = errorMessage(error) - console.warn('[SubagentOrchestratorTool] Failed to finalize subagent tape fork:', { + console.warn('[SubagentOrchestratorTool] Failed to link finalized subagent Tape:', { parentSessionId, childSessionId: task.sessionId, status: task.status, @@ -619,7 +638,7 @@ export class SubagentOrchestratorTool { continue } - // Cancellation settlement makes discard safe, but a slow tape backend must not turn the + // Cancellation settlement makes the frozen link safe, but a slow Tape backend must not turn the // run deadline into another unbounded wait for foreground or polling callers. void this.finalizeTaskTape(finalization) .then(() => this.updateRunStatus(run)) diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index 66b36e541..f316a092d 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -14,7 +14,9 @@ import type { PermissionMode, SendMessageInput, SessionGenerationSettings, - SessionKind + SessionKind, + SubagentTapeLinkInput, + SubagentTapeLinkReceipt } from '@shared/types/agent-interface' import type { ISkillPresenter } from '@shared/types/skill' import type { AgentMemoryCategory } from '@shared/types/agent-memory' @@ -111,16 +113,7 @@ export interface AgentToolRuntimePort { count?: number }): Promise createSubagentSession(input: CreateSubagentSessionInput): Promise - mergeSubagentTape?( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise - discardSubagentTape?( - parentSessionId: string, - childSessionId: string, - meta?: Record - ): Promise + linkSubagentTape?(input: SubagentTapeLinkInput): Promise sendConversationMessage(conversationId: string, content: string | SendMessageInput): Promise cancelConversation(conversationId: string): Promise subscribeDeepChatSessionUpdates( diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 0e3c26fe2..1230199f1 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -111,6 +111,30 @@ export interface AgentTapeContextResult { entries: AgentTapeContextEntry[] } +export type SubagentTapeLinkOutcome = 'completed' | 'error' | 'cancelled' + +export interface SubagentTapeLinkInput { + parentSessionId: string + childSessionId: string + runId: string + taskId: string + slotId: string + taskTitle: string + outcome: SubagentTapeLinkOutcome + resultSummary: string | null +} + +export interface SubagentTapeLinkReceipt { + linkEntry: { + sessionId: string + entryId: number + } + childSessionId: string + childHeadEntryId: number + childEntryCount: number + outcome: SubagentTapeLinkOutcome +} + export interface DeepChatSessionState { status: SessionStatus providerId: string diff --git a/test/main/agent/manager/agentManager.test.ts b/test/main/agent/manager/agentManager.test.ts index 0f9e737ec..d55dbd7bb 100644 --- a/test/main/agent/manager/agentManager.test.ts +++ b/test/main/agent/manager/agentManager.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentManager, AppSessionNotFoundError } from '@/agent/manager/agentManager' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import type { AgentDescriptor } from '@/agent/shared/agentDescriptors' +import type { SubagentTapeLinkInput } from '@shared/types/agent-interface' import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec' import { createDeepChatAgentBackendFixture } from './deepChatAgentBackendFixture' @@ -33,8 +34,15 @@ const implementation = (name: string) => getSessionCompactionState: vi.fn(), compactSession: vi.fn(), invalidateSessionSystemPromptCache: vi.fn(), - mergeSubagentTape: vi.fn(), - discardSubagentTape: vi.fn(), + linkSubagentTape: vi.fn((input: SubagentTapeLinkInput) => + Promise.resolve({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome + }) + ), getActiveGeneration: vi.fn().mockReturnValue(null), cancelGenerationByEventId: vi.fn().mockResolvedValue(false), getMessage: vi.fn().mockResolvedValue(null) @@ -53,13 +61,7 @@ const directBackend = (selected: ReturnType) => ({ listPendingInputs: (sessionId: string) => selected.listPendingInputs(sessionId) }, subagent: { - mergeTape: (parentSessionId: string, childSessionId: string, meta?: Record) => - selected.mergeSubagentTape(parentSessionId, childSessionId, meta), - discardTape: ( - parentSessionId: string, - childSessionId: string, - meta?: Record - ) => selected.discardSubagentTape(parentSessionId, childSessionId, meta) + linkTape: (input: SubagentTapeLinkInput) => selected.linkSubagentTape(input) }, generationControl: { getActiveGeneration: (sessionId: string) => selected.getActiveGeneration(sessionId), @@ -152,11 +154,21 @@ describe('AgentManager', () => { await manager.resolveTransferSource(sessionId).facet.listPendingInputs(sessionId) const subagent = manager.resolveSubagentFacet(sessionId) - await subagent.facet.mergeTape(sessionId, toAppSessionId('child')) + const linkInput = { + parentSessionId: sessionId, + childSessionId: toAppSessionId('child'), + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed' as const, + resultSummary: 'Done' + } + await subagent.facet.linkTape(linkInput) const selected = kind === 'deepchat' ? deepchat : acp expect(selected.listPendingInputs).toHaveBeenCalledWith('session') - expect(selected.mergeSubagentTape).toHaveBeenCalledWith('session', 'child', undefined) + expect(selected.linkSubagentTape).toHaveBeenCalledWith(linkInput) expect(subagent.kind).toBe(kind) } ) diff --git a/test/main/agent/manager/deepChatAgentBackend.test.ts b/test/main/agent/manager/deepChatAgentBackend.test.ts index 7c0fd8413..438ea4e0f 100644 --- a/test/main/agent/manager/deepChatAgentBackend.test.ts +++ b/test/main/agent/manager/deepChatAgentBackend.test.ts @@ -35,8 +35,13 @@ const createPort = (): DeepChatAgentBackendPort => ({ getSessionCompactionState: vi.fn().mockResolvedValue({}), compactSession: vi.fn().mockResolvedValue({ compacted: false, state: {} }), invalidateSessionSystemPromptCache: vi.fn(), - mergeSubagentTape: vi.fn().mockResolvedValue(undefined), - discardSubagentTape: vi.fn().mockResolvedValue(undefined), + linkSubagentTape: vi.fn().mockImplementation(async (input) => ({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome + })), getActiveGeneration: vi.fn().mockReturnValue({ eventId: 'message', runId: 'run' }), cancelGenerationByEventId: vi.fn().mockResolvedValue(true) }) @@ -96,6 +101,16 @@ describe('DeepChatAgentBackend', () => { const deepchat = createDeepChatAgentBackendFixture(port) const parent = toAppSessionId('parent') const child = toAppSessionId('child') + const linkInput = { + parentSessionId: parent, + childSessionId: child, + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed' as const, + resultSummary: 'Done' + } await deepchat.transferSource.hasMessages(parent) await deepchat.transferSource.listPendingInputs(parent) @@ -105,8 +120,7 @@ describe('DeepChatAgentBackend', () => { modelId: 'model', permissionMode: 'full_access' }) - await deepchat.subagent.mergeTape(parent, child, { outcome: 'merged' }) - await deepchat.subagent.discardTape(parent, child, { outcome: 'discarded' }) + await deepchat.subagent.linkTape(linkInput) expect(deepchat.generationControl.getActiveGeneration(parent)).toEqual({ eventId: 'message', runId: 'run' @@ -121,12 +135,7 @@ describe('DeepChatAgentBackend', () => { modelId: 'model', permissionMode: 'full_access' }) - expect(port.mergeSubagentTape).toHaveBeenCalledWith('parent', 'child', { - outcome: 'merged' - }) - expect(port.discardSubagentTape).toHaveBeenCalledWith('parent', 'child', { - outcome: 'discarded' - }) + expect(port.linkSubagentTape).toHaveBeenCalledWith(linkInput) expect(port.getActiveGeneration).toHaveBeenCalledWith('parent') expect(port.cancelGenerationByEventId).toHaveBeenCalledWith('parent', 'message') }) diff --git a/test/main/agent/manager/directAcpAgentBackend.test.ts b/test/main/agent/manager/directAcpAgentBackend.test.ts index eb80c7fc5..0b87956f1 100644 --- a/test/main/agent/manager/directAcpAgentBackend.test.ts +++ b/test/main/agent/manager/directAcpAgentBackend.test.ts @@ -83,8 +83,13 @@ function createHarness() { }) } const tape = { - mergeSubagentTape: vi.fn().mockResolvedValue(undefined), - discardSubagentTape: vi.fn().mockResolvedValue(undefined) + linkSubagentTape: vi.fn().mockImplementation(async (input) => ({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome + })) } const deleteDurableSession = vi.fn().mockResolvedValue(undefined) const resolveInput = vi.fn().mockResolvedValue({ @@ -266,9 +271,19 @@ describe('direct ACP agent backend', () => { const harness = createHarness() const handle = harness.backend.open(sessionId, descriptor) const childId = toAppSessionId('child') + const linkInput = { + parentSessionId: sessionId, + childSessionId: childId, + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed' as const, + resultSummary: 'Done' + } await expect(harness.backend.transferSource.hasMessages(sessionId)).resolves.toBe(true) - await harness.backend.subagent.mergeTape(sessionId, childId, { outcome: 'merged' }) + await harness.backend.subagent.linkTape(linkInput) expect(harness.backend.generationControl.getActiveGeneration(sessionId)).toEqual({ eventId: 'message', runId: 'request' @@ -278,9 +293,7 @@ describe('direct ACP agent backend', () => { ).resolves.toBe(true) await handle.close() - expect(harness.tape.mergeSubagentTape).toHaveBeenCalledWith(sessionId, childId, { - outcome: 'merged' - }) + expect(harness.tape.linkSubagentTape).toHaveBeenCalledWith(linkInput) expect(harness.runtime.cleanupSession).toHaveBeenCalledWith(sessionId) expect(harness.deleteDurableSession).toHaveBeenCalledWith(sessionId) expect(harness.sessionState.destroySession).toHaveBeenCalledWith(sessionId) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index b4fd45712..09d617b92 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -4098,7 +4098,7 @@ describe('DeepChatTapeService', () => { ).toBe(true) }) - it('records external subagent tape fork merge and discard without copying child entries', () => { + it('links a frozen subagent Tape without copying child entries and retries idempotently', () => { const { table, entries } = createTapeTableMock() const service = new DeepChatTapeService({ deepchatTapeEntriesTable: table, @@ -4107,27 +4107,61 @@ describe('DeepChatTapeService', () => { table.ensureBootstrapAnchor('parent') table.ensureBootstrapAnchor('child') - service.recordExternalForkMerge('parent', 'child', 'child', { + table.appendEvent({ sessionId: 'child', name: 'child/result', data: { text: 'done' } }) + const input = { + parentSessionId: 'parent', + childSessionId: 'child', runId: 'run-1', taskId: 'task-1', - status: 'completed' + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed' as const, + resultSummary: 'Done' + } + const first = service.linkSubagentTape(input) + table.appendEvent({ sessionId: 'child', name: 'child/late', data: { text: 'late' } }) + const retry = service.linkSubagentTape(input) + + expect(first).toEqual({ + linkEntry: { sessionId: 'parent', entryId: 2 }, + childSessionId: 'child', + childHeadEntryId: 2, + childEntryCount: 2, + outcome: 'completed' }) - service.recordExternalForkDiscard('parent', 'child-2', 'child-2', { - runId: 'run-2', - taskId: 'task-2', - status: 'cancelled' + expect(retry).toEqual(first) + const links = entries.filter( + (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' + ) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + source_type: 'subagent', + source_id: 'child', + source_seq: 2 }) - expect( - entries.filter((entry) => entry.session_id === 'parent' && entry.name === 'fork/merge') - ).toHaveLength(1) + entries.some((entry) => entry.session_id === 'parent' && entry.name === 'child/result') + ).toBe(false) + expect(() => service.linkSubagentTape({ ...input, outcome: 'error' })).toThrow( + 'Subagent Tape link conflicts with finalized task run-1/task-1.' + ) + + table.ensureBootstrapAnchor('child-2') expect( - entries.filter((entry) => entry.session_id === 'parent' && entry.name === 'fork/discard') - ).toHaveLength(1) + service.linkSubagentTape({ + ...input, + childSessionId: 'child-2', + outcome: 'error' + }) + ).toMatchObject({ + childSessionId: 'child-2', + outcome: 'error' + }) expect( - entries.some((entry) => entry.session_id === 'parent' && entry.name === 'message/user') - ).toBe(false) - expect(entries.some((entry) => entry.session_id === 'child')).toBe(true) + entries.filter( + (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' + ) + ).toHaveLength(2) }) it('uses effective message facts after replacement and retraction events', () => { diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts index 2c817eb56..b8220c4eb 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { SessionRecord, SessionWithState } from '@shared/types/agent-interface' +import type { + SessionRecord, + SessionWithState, + SubagentTapeLinkInput +} from '@shared/types/agent-interface' import { SessionAgentAssignmentCoordinator, type SessionAgentAssignmentDependencies @@ -139,8 +143,15 @@ function createHarness(initialSessions: SessionRecord[] = [createSession()]) { kind: 'deepchat', descriptor: { id: 'source', kind: 'deepchat' }, facet: { - mergeTape: vi.fn().mockResolvedValue(undefined), - discardTape: vi.fn().mockResolvedValue(undefined) + linkTape: vi.fn((input: SubagentTapeLinkInput) => + Promise.resolve({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome + }) + ) } })) } @@ -491,9 +502,39 @@ describe('SessionAgentAssignmentCoordinator', () => { }) ]) - await expect(harness.coordinator.mergeSubagentTape('parent', 'child')).rejects.toThrow( - 'Session child is not a child of parent.' - ) + await expect( + harness.coordinator.linkSubagentTape({ + parentSessionId: 'parent', + childSessionId: 'child', + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed', + resultSummary: null + }) + ).rejects.toThrow('Session child is not a child of parent.') + expect(harness.runtime.resolveSubagentFacet).not.toHaveBeenCalled() + }) + + it('rejects a regular session even when malformed data gives it a parent', async () => { + const harness = createHarness([ + createSession({ id: 'parent' }), + createSession({ id: 'child', sessionKind: 'regular', parentSessionId: 'parent' }) + ]) + + await expect( + harness.coordinator.linkSubagentTape({ + parentSessionId: 'parent', + childSessionId: 'child', + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed', + resultSummary: null + }) + ).rejects.toThrow('Session child is not a child of parent.') expect(harness.runtime.resolveSubagentFacet).not.toHaveBeenCalled() }) }) diff --git a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts index fe4f482ba..092ce997e 100644 --- a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts +++ b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts @@ -6,6 +6,7 @@ import { createDirectAcpAgentBackend } from '@/agent/manager/directAcpAgentBacke import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec' import type { AcpAgentDescriptor } from '@/agent/shared/agentDescriptors' import type { AppSessionId } from '@/agent/shared/agentSessionIds' +import type { SubagentTapeLinkInput } from '@shared/types/agent-interface' import { AgentRepository } from '@/presenter/agentRepository' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { createDeepChatAgentBackendFixture } from '../../agent/manager/deepChatAgentBackendFixture' @@ -51,6 +52,16 @@ vi.mock('@/presenter', () => ({ } })) +const linkTape = vi.fn((input: SubagentTapeLinkInput) => + Promise.resolve({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome + }) +) + import { eventBus } from '@/eventbus' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' @@ -112,8 +123,7 @@ function createMockDeepChatAgent() { listMessagesPage: vi.fn().mockResolvedValue({ messages: [], nextCursor: null, hasMore: false }), hasMessages: vi.fn().mockResolvedValue(false), listPendingInputs: vi.fn().mockResolvedValue([]), - mergeSubagentTape: vi.fn().mockResolvedValue(undefined), - discardSubagentTape: vi.fn().mockResolvedValue(undefined), + linkSubagentTape: linkTape, getActiveGeneration: vi.fn().mockReturnValue(null), cancelGenerationByEventId: vi.fn().mockResolvedValue(false), getSessionCompactionState: vi.fn().mockResolvedValue({ @@ -902,8 +912,7 @@ describe('Session application coordinators', () => { ), listPendingInputs: vi.fn().mockResolvedValue([]), setSessionAgentContext: vi.fn().mockResolvedValue(undefined), - mergeSubagentTape: vi.fn().mockResolvedValue(undefined), - discardSubagentTape: vi.fn().mockResolvedValue(undefined), + linkSubagentTape: linkTape, getActiveGeneration: vi.fn().mockReturnValue(null), cancelGenerationByEventId: vi.fn().mockResolvedValue(false), processMessage: vi @@ -2819,12 +2828,9 @@ describe('Session application coordinators', () => { }) describe('subagent tape facets', () => { - it.each([ - ['deepchat', 'mergeSubagentTape', 'mergeSubagentTape'], - ['acp-parent', 'discardSubagentTape', 'discardSubagentTape'] - ] as const)( - 'routes %s parent tape operations through required facets', - async (agentId, action, method) => { + it.each(['deepchat', 'acp-parent'] as const)( + 'routes %s parent Tape links through required facets', + async (agentId) => { sqlitePresenter.newSessionsTable.get.mockImplementation((sessionId: string) => { if (sessionId === 'parent') { return { id: 'parent', agent_id: agentId, session_kind: 'regular' } @@ -2840,9 +2846,19 @@ describe('Session application coordinators', () => { return undefined }) - await assignment[action]('parent', 'child', { source: 'test' }) + const input = { + parentSessionId: 'parent', + childSessionId: 'child', + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed' as const, + resultSummary: 'Done' + } + await assignment.linkSubagentTape(input) - expect(deepChatAgent[method]).toHaveBeenCalledWith('parent', 'child', { source: 'test' }) + expect(deepChatAgent.linkSubagentTape).toHaveBeenCalledWith(input) } ) @@ -2853,11 +2869,20 @@ describe('Session application coordinators', () => { return undefined }) - await expect(assignment.mergeSubagentTape('parent', 'child')).rejects.toThrow( - 'Session child is not a child of parent.' - ) + await expect( + assignment.linkSubagentTape({ + parentSessionId: 'parent', + childSessionId: 'child', + runId: 'run', + taskId: 'task', + slotId: 'reviewer', + taskTitle: 'Review', + outcome: 'completed', + resultSummary: null + }) + ).rejects.toThrow('Session child is not a child of parent.') expect(agentManager.resolveSubagentFacet).not.toHaveBeenCalled() - expect(deepChatAgent.mergeSubagentTape).not.toHaveBeenCalled() + expect(deepChatAgent.linkSubagentTape).not.toHaveBeenCalled() }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 680ed2333..278706842 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -5,6 +5,18 @@ import { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@/presenter/toolPresenter/agentTools/subagentOrchestratorTool' import type { ConversationSessionInfo } from '@/presenter/toolPresenter/runtimePorts' +import type { SubagentTapeLinkInput, SubagentTapeLinkReceipt } from '@shared/types/agent-interface' + +const buildTapeLinkReceipt = (input: SubagentTapeLinkInput): SubagentTapeLinkReceipt => ({ + linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, + childSessionId: input.childSessionId, + childHeadEntryId: 2, + childEntryCount: 2, + outcome: input.outcome +}) + +const createTapeLinkMock = () => + vi.fn(async (input: SubagentTapeLinkInput) => buildTapeLinkReceipt(input)) const buildSessionInfo = ( overrides: Partial = {} @@ -53,8 +65,7 @@ const buildRuntimePort = ( sendConversationMessage: vi.fn().mockResolvedValue(undefined), cancelConversation: vi.fn().mockResolvedValue(undefined), subscribeDeepChatSessionUpdates: vi.fn(() => () => undefined), - mergeSubagentTape: vi.fn().mockResolvedValue(undefined), - discardSubagentTape: vi.fn().mockResolvedValue(undefined), + linkSubagentTape: createTapeLinkMock(), getSkillPresenter: vi.fn(() => ({})), getYoBrowserToolHandler: vi.fn(() => ({})), getFilePresenter: vi.fn(() => ({ @@ -285,7 +296,7 @@ describe('SubagentOrchestratorTool', () => { } }) - it('waits for child cancellation but not tape discard after a deadline', async () => { + it('waits for child cancellation but not a blocked Tape link after a deadline', async () => { vi.useFakeTimers() try { @@ -304,12 +315,16 @@ describe('SubagentOrchestratorTool', () => { settleCancellation = resolve }) ) - const discard = createDeferredPromise() - const discardSubagentTape = vi.fn(() => discard.promise) + const link = createDeferredPromise() + let linkInput: SubagentTapeLinkInput | undefined + const linkSubagentTape = vi.fn((input: SubagentTapeLinkInput) => { + linkInput = input + return link.promise + }) const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn().mockResolvedValue(childSession), cancelConversation, - discardSubagentTape + linkSubagentTape }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -332,7 +347,7 @@ describe('SubagentOrchestratorTool', () => { 'cancelled' ) expect(cancelConversation).toHaveBeenCalledWith(childSession.sessionId) - expect(discardSubagentTape).not.toHaveBeenCalled() + expect(linkSubagentTape).not.toHaveBeenCalled() settleCancellation?.() await Promise.resolve() @@ -342,14 +357,16 @@ describe('SubagentOrchestratorTool', () => { parentSession.sessionId ) - expect(discardSubagentTape).toHaveBeenCalledWith( - parentSession.sessionId, - childSession.sessionId, - expect.objectContaining({ status: 'cancelled' }) + expect(linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: parentSession.sessionId, + childSessionId: childSession.sessionId, + outcome: 'cancelled' + }) ) expect((waited.rawData?.toolResult as any).subagentFinal).toBeTruthy() - discard.resolve(undefined) + link.resolve(buildTapeLinkReceipt(linkInput!)) await Promise.resolve() await Promise.resolve() } finally { @@ -357,19 +374,20 @@ describe('SubagentOrchestratorTool', () => { } }) - it('returns at the deadline when a completed child tape merge is still blocked', async () => { + it('returns at the deadline when a completed child Tape link is still blocked', async () => { vi.useFakeTimers() try { const parentSession = buildSessionInfo() const childSession = buildSessionInfo({ - sessionId: 'blocked-merge-child', + sessionId: 'blocked-completed-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, subagentEnabled: false, availableSubagentSlots: [] }) - const merge = createDeferredPromise() + const link = createDeferredPromise() + let linkInput: SubagentTapeLinkInput | undefined let listener: ((update: DeepChatInternalSessionUpdate) => void) | undefined const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn().mockResolvedValue(childSession), @@ -379,7 +397,10 @@ describe('SubagentOrchestratorTool', () => { listener = undefined } }), - mergeSubagentTape: vi.fn(() => merge.promise) + linkSubagentTape: vi.fn((input: SubagentTapeLinkInput) => { + linkInput = input + return link.promise + }) }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -387,7 +408,7 @@ describe('SubagentOrchestratorTool', () => { { mode: 'chain', runTimeoutMs: 1000, - tasks: [{ slotId: 'reviewer', title: 'Blocked merge', prompt: 'Finish normally.' }] + tasks: [{ slotId: 'reviewer', title: 'Blocked link', prompt: 'Finish normally.' }] }, parentSession.sessionId ) @@ -399,7 +420,10 @@ describe('SubagentOrchestratorTool', () => { status: 'idle' }) await vi.advanceTimersByTimeAsync(0) - expect(runtimePort.mergeSubagentTape).toHaveBeenCalledOnce() + expect(runtimePort.linkSubagentTape).toHaveBeenCalledOnce() + expect(runtimePort.linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'completed' }) + ) await vi.advanceTimersByTimeAsync(1000) @@ -422,26 +446,27 @@ describe('SubagentOrchestratorTool', () => { ) ).resolves.toBeTruthy() - merge.resolve(undefined) + link.resolve(buildTapeLinkReceipt(linkInput!)) await vi.advanceTimersByTimeAsync(0) } finally { vi.useRealTimers() } }) - it('returns at the deadline when an errored child tape discard is still blocked', async () => { + it('returns at the deadline when an errored child Tape link is still blocked', async () => { vi.useFakeTimers() try { const parentSession = buildSessionInfo() const childSession = buildSessionInfo({ - sessionId: 'blocked-discard-child', + sessionId: 'blocked-error-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, subagentEnabled: false, availableSubagentSlots: [] }) - const discard = createDeferredPromise() + const link = createDeferredPromise() + let linkInput: SubagentTapeLinkInput | undefined let listener: ((update: DeepChatInternalSessionUpdate) => void) | undefined const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn().mockResolvedValue(childSession), @@ -451,7 +476,10 @@ describe('SubagentOrchestratorTool', () => { listener = undefined } }), - discardSubagentTape: vi.fn(() => discard.promise) + linkSubagentTape: vi.fn((input: SubagentTapeLinkInput) => { + linkInput = input + return link.promise + }) }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -459,7 +487,7 @@ describe('SubagentOrchestratorTool', () => { { mode: 'chain', runTimeoutMs: 1000, - tasks: [{ slotId: 'reviewer', title: 'Blocked discard', prompt: 'Fail normally.' }] + tasks: [{ slotId: 'reviewer', title: 'Blocked link', prompt: 'Fail normally.' }] }, parentSession.sessionId ) @@ -471,7 +499,10 @@ describe('SubagentOrchestratorTool', () => { status: 'error' }) await vi.advanceTimersByTimeAsync(0) - expect(runtimePort.discardSubagentTape).toHaveBeenCalledOnce() + expect(runtimePort.linkSubagentTape).toHaveBeenCalledOnce() + expect(runtimePort.linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'error' }) + ) await vi.advanceTimersByTimeAsync(1000) @@ -484,14 +515,14 @@ describe('SubagentOrchestratorTool', () => { expect(finalProgress.tasks[0].status).toBe('error') expect(runtimePort.cancelConversation).not.toHaveBeenCalled() - discard.resolve(undefined) + link.resolve(buildTapeLinkReceipt(linkInput!)) await vi.advanceTimersByTimeAsync(0) } finally { vi.useRealTimers() } }) - it('registers cancellation before discarding a child created after the deadline', async () => { + it('registers cancellation before linking a child created after the deadline', async () => { vi.useFakeTimers() try { @@ -506,11 +537,11 @@ describe('SubagentOrchestratorTool', () => { const childCreation = createDeferredPromise() const cancellation = createDeferredPromise() const cancelConversation = vi.fn(() => cancellation.promise) - const discardSubagentTape = vi.fn().mockResolvedValue(undefined) + const linkSubagentTape = createTapeLinkMock() const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn(() => childCreation.promise), cancelConversation, - discardSubagentTape + linkSubagentTape }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -531,15 +562,17 @@ describe('SubagentOrchestratorTool', () => { expect(cancelConversation).toHaveBeenCalledWith(childSession.sessionId) await tool.call({ operation: 'info', runId }, parentSession.sessionId) - expect(discardSubagentTape).not.toHaveBeenCalled() + expect(linkSubagentTape).not.toHaveBeenCalled() cancellation.resolve(undefined) await vi.advanceTimersByTimeAsync(0) - expect(discardSubagentTape).toHaveBeenCalledWith( - parentSession.sessionId, - childSession.sessionId, - expect.objectContaining({ status: 'cancelled' }) + expect(linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: parentSession.sessionId, + childSessionId: childSession.sessionId, + outcome: 'cancelled' + }) ) } finally { vi.useRealTimers() @@ -685,7 +718,7 @@ describe('SubagentOrchestratorTool', () => { expect(cancelConversation).toHaveBeenCalledWith(childSession.sessionId) }) - it('records completed child sessions as merged tape forks', async () => { + it('records completed child sessions as finalized Tape links', async () => { let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null const parentSession = buildSessionInfo() const childSession = buildSessionInfo({ @@ -696,8 +729,7 @@ describe('SubagentOrchestratorTool', () => { subagentEnabled: false, availableSubagentSlots: [] }) - const mergeSubagentTape = vi.fn().mockResolvedValue(undefined) - const discardSubagentTape = vi.fn().mockResolvedValue(undefined) + const linkSubagentTape = createTapeLinkMock() const tool = new SubagentOrchestratorTool({ resolveConversationWorkdir: vi.fn().mockResolvedValue(parentSession.projectDir), @@ -727,8 +759,7 @@ describe('SubagentOrchestratorTool', () => { listener = null } }), - mergeSubagentTape, - discardSubagentTape, + linkSubagentTape, getSkillPresenter: vi.fn(() => ({})), getYoBrowserToolHandler: vi.fn(() => ({})), getFilePresenter: vi.fn(() => ({ @@ -761,27 +792,26 @@ describe('SubagentOrchestratorTool', () => { parentSession.sessionId ) - expect(mergeSubagentTape).toHaveBeenCalledWith( - parentSession.sessionId, - childSession.sessionId, + expect(linkSubagentTape).toHaveBeenCalledWith( expect.objectContaining({ + parentSessionId: parentSession.sessionId, + childSessionId: childSession.sessionId, taskId: 'task-review', slotId: 'reviewer', - status: 'completed', - title: 'Review task' + outcome: 'completed', + taskTitle: 'Review task' }) ) - expect(discardSubagentTape).not.toHaveBeenCalled() }) - it('leaves subagent tape unfinalized when merge fails so it can be retried', async () => { - const mergeSubagentTape = vi + it('leaves a subagent Tape unfinalized when linking fails so it can be retried', async () => { + const linkSubagentTape = vi .fn() - .mockRejectedValueOnce(new Error('merge failed')) - .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('link failed')) + .mockImplementationOnce(async (input: SubagentTapeLinkInput) => buildTapeLinkReceipt(input)) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const tool = new SubagentOrchestratorTool({ - mergeSubagentTape + linkSubagentTape } as any) const task = { sessionId: 'child-session', @@ -799,7 +829,7 @@ describe('SubagentOrchestratorTool', () => { task }) expect(task.tapeFinalized).toBe(false) - expect(task.tapeFinalizeError).toBe('merge failed') + expect(task.tapeFinalizeError).toBe('link failed') await (tool as any).finalizeTaskTape({ parentSessionId: 'parent-session', @@ -807,13 +837,13 @@ describe('SubagentOrchestratorTool', () => { task }) - expect(mergeSubagentTape).toHaveBeenCalledTimes(2) + expect(linkSubagentTape).toHaveBeenCalledTimes(2) expect(task.tapeFinalized).toBe(true) expect(task.tapeFinalizeError).toBeUndefined() warnSpy.mockRestore() }) - it('marks subagent tape finalized when runtime has no tape merge support', async () => { + it('keeps a subagent Tape unfinalized when the runtime has no link capability', async () => { const tool = new SubagentOrchestratorTool({} as any) const task = { sessionId: 'child-session', @@ -831,8 +861,38 @@ describe('SubagentOrchestratorTool', () => { task }) - expect(task.tapeFinalized).toBe(true) - expect(task.tapeFinalizeError).toBeUndefined() + expect(task.tapeFinalized).toBe(false) + expect(task.tapeFinalizeError).toBe('Subagent Tape link capability is unavailable.') + }) + + it('rejects a link receipt that does not match the finalized child', async () => { + const linkSubagentTape = vi.fn(async (input: SubagentTapeLinkInput) => ({ + ...buildTapeLinkReceipt(input), + childSessionId: 'different-child' + })) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const tool = new SubagentOrchestratorTool({ linkSubagentTape } as any) + const task = { + sessionId: 'child-session', + tapeFinalized: false, + taskId: 'task-review', + slotId: 'reviewer', + title: 'Review task', + status: 'completed', + resultSummary: 'Done' + } + + await (tool as any).finalizeTaskTape({ + parentSessionId: 'parent-session', + runId: 'run-1', + task + }) + + expect(task.tapeFinalized).toBe(false) + expect(task.tapeFinalizeError).toBe( + 'Subagent Tape link receipt does not match the finalized task.' + ) + warnSpy.mockRestore() }) it('retries failed subagent tape finalization on terminal wait', async () => { @@ -846,10 +906,10 @@ describe('SubagentOrchestratorTool', () => { subagentEnabled: false, availableSubagentSlots: [] }) - const mergeSubagentTape = vi + const linkSubagentTape = vi .fn() - .mockRejectedValueOnce(new Error('merge failed')) - .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('link failed')) + .mockImplementationOnce(async (input: SubagentTapeLinkInput) => buildTapeLinkReceipt(input)) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const tool = new SubagentOrchestratorTool({ @@ -880,7 +940,7 @@ describe('SubagentOrchestratorTool', () => { listener = null } }), - mergeSubagentTape, + linkSubagentTape, getSkillPresenter: vi.fn(() => ({})), getYoBrowserToolHandler: vi.fn(() => ({})), getFilePresenter: vi.fn(() => ({ @@ -921,7 +981,7 @@ describe('SubagentOrchestratorTool', () => { ) const finalProgress = JSON.parse((waited.rawData?.toolResult as any).subagentFinal) - expect(mergeSubagentTape).toHaveBeenCalledTimes(2) + expect(linkSubagentTape).toHaveBeenCalledTimes(2) expect(waited.rawData?.isError).toBe(false) expect(waited.content).not.toContain('Tape Finalization: failed') expect(finalProgress.tasks[0]).toMatchObject({ @@ -942,7 +1002,7 @@ describe('SubagentOrchestratorTool', () => { subagentEnabled: false, availableSubagentSlots: [] }) - const mergeSubagentTape = vi.fn().mockRejectedValue(new Error('merge still failed')) + const linkSubagentTape = vi.fn().mockRejectedValue(new Error('link still failed')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const tool = new SubagentOrchestratorTool({ @@ -973,7 +1033,7 @@ describe('SubagentOrchestratorTool', () => { listener = null } }), - mergeSubagentTape, + linkSubagentTape, getSkillPresenter: vi.fn(() => ({})), getYoBrowserToolHandler: vi.fn(() => ({})), getFilePresenter: vi.fn(() => ({ @@ -1014,27 +1074,27 @@ describe('SubagentOrchestratorTool', () => { ) const waitedProgress = JSON.parse((waited.rawData?.toolResult as any).subagentFinal) - expect(mergeSubagentTape).toHaveBeenCalledTimes(2) + expect(linkSubagentTape).toHaveBeenCalledTimes(2) expect(waited.rawData?.isError).toBe(true) - expect(waited.content).toContain('Tape Finalization: failed: merge still failed') + expect(waited.content).toContain('Tape Finalization: failed: link still failed') expect(waitedProgress.tasks[0]).toMatchObject({ tapeFinalized: false, - tapeFinalizeError: 'merge still failed' + tapeFinalizeError: 'link still failed' }) const info = await tool.call({ operation: 'info', runId }, parentSession.sessionId) - expect(mergeSubagentTape).toHaveBeenCalledTimes(3) + expect(linkSubagentTape).toHaveBeenCalledTimes(3) expect(info.rawData?.isError).toBe(true) const logged = await tool.call({ operation: 'log', runId }, parentSession.sessionId) - expect(mergeSubagentTape).toHaveBeenCalledTimes(4) + expect(linkSubagentTape).toHaveBeenCalledTimes(4) expect(logged.rawData?.isError).toBe(true) warnSpy.mockRestore() }) - it('rechecks cancellation after a blocked handoff and cancels again before tape discard', async () => { + it('rechecks cancellation after a blocked handoff before linking the Tape', async () => { vi.useFakeTimers() try { @@ -1051,12 +1111,12 @@ describe('SubagentOrchestratorTool', () => { const cancellations = [createDeferredPromise(), createDeferredPromise()] let cancellationIndex = 0 const cancelConversation = vi.fn(() => cancellations[cancellationIndex++]!.promise) - const discardSubagentTape = vi.fn().mockResolvedValue(undefined) + const linkSubagentTape = createTapeLinkMock() const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn().mockResolvedValue(childSession), sendConversationMessage: vi.fn(() => handoff.promise), cancelConversation, - discardSubagentTape + linkSubagentTape }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -1084,7 +1144,7 @@ describe('SubagentOrchestratorTool', () => { abortController.abort() await expect(runPromise).rejects.toThrow('subagent_orchestrator cancelled.') expect(cancelConversation).toHaveBeenCalledTimes(1) - expect(discardSubagentTape).not.toHaveBeenCalled() + expect(linkSubagentTape).not.toHaveBeenCalled() handoff.resolve(undefined) await vi.advanceTimersByTimeAsync(0) @@ -1092,14 +1152,16 @@ describe('SubagentOrchestratorTool', () => { cancellations[0].resolve(undefined) await vi.advanceTimersByTimeAsync(0) - expect(discardSubagentTape).not.toHaveBeenCalled() + expect(linkSubagentTape).not.toHaveBeenCalled() cancellations[1].resolve(undefined) await vi.advanceTimersByTimeAsync(0) - expect(discardSubagentTape).toHaveBeenCalledWith( - parentSession.sessionId, - childSession.sessionId, - expect.objectContaining({ status: 'cancelled' }) + expect(linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: parentSession.sessionId, + childSessionId: childSession.sessionId, + outcome: 'cancelled' + }) ) } finally { vi.useRealTimers() @@ -1123,12 +1185,12 @@ describe('SubagentOrchestratorTool', () => { const cancellation = createDeferredPromise() const cancelConversation = vi.fn(() => cancellation.promise) const sendConversationMessage = vi.fn().mockResolvedValue(undefined) - const discardSubagentTape = vi.fn().mockResolvedValue(undefined) + const linkSubagentTape = createTapeLinkMock() const runtimePort = buildRuntimePort(parentSession, { createSubagentSession: vi.fn(() => childCreation.promise), sendConversationMessage, cancelConversation, - discardSubagentTape + linkSubagentTape }) const tool = new SubagentOrchestratorTool(runtimePort as any) @@ -1158,14 +1220,16 @@ describe('SubagentOrchestratorTool', () => { await vi.advanceTimersByTimeAsync(0) expect(sendConversationMessage).not.toHaveBeenCalled() expect(cancelConversation).toHaveBeenCalledWith(childSession.sessionId) - expect(discardSubagentTape).not.toHaveBeenCalled() + expect(linkSubagentTape).not.toHaveBeenCalled() cancellation.resolve(undefined) await vi.advanceTimersByTimeAsync(0) - expect(discardSubagentTape).toHaveBeenCalledWith( - parentSession.sessionId, - childSession.sessionId, - expect.objectContaining({ status: 'cancelled' }) + expect(linkSubagentTape).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: parentSession.sessionId, + childSessionId: childSession.sessionId, + outcome: 'cancelled' + }) ) } finally { vi.useRealTimers() From 1f3928977bf3db4adc5eedc5ba76420a9197cd76 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:24:59 +0800 Subject: [PATCH 04/17] feat(tape): add linked tape views --- .../presenter/agentRuntimePresenter/index.ts | 9 +- .../agentRuntimePresenter/tapeService.ts | 370 ++++++++- .../tables/deepchatTapeEntries.ts | 470 +++++++++++ .../tables/deepchatTapeSearchProjection.ts | 184 +++++ src/shared/types/agent-interface.d.ts | 6 + .../agentRuntimePresenter.test.ts | 40 +- .../agentRuntimePresenter/tapeService.test.ts | 734 ++++++++++++++++++ 7 files changed, 1803 insertions(+), 10 deletions(-) diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 622797e0d..6e7f792e3 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -1585,7 +1585,9 @@ export class AgentRuntimePresenter { query: string, options?: AgentTapeSearchOptions ): Promise { - this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + if (options?.scope === undefined || options.scope === 'current') { + this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + } return this.tapeService.search(sessionId, query, options) } @@ -1594,7 +1596,10 @@ export class AgentRuntimePresenter { entryIds: number[], options?: AgentTapeContextOptions ): Promise { - this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + const sourceSessionId = options?.sourceSessionId?.trim() + if (!sourceSessionId || sourceSessionId === sessionId) { + this.tapeService.ensureSessionTapeReady(sessionId, this.messageStore) + } return this.tapeService.getContext(sessionId, entryIds, options) } diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index 2646f11bc..7c55ec2f1 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -10,6 +10,7 @@ import type { AgentTapeContextResult, AgentTapeHandoffState, AgentTapeSearchOptions, + AgentTapeViewScope, ChatMessageRecord, SubagentTapeLinkInput, SubagentTapeLinkOutcome, @@ -32,6 +33,7 @@ import type { import type { DeepChatMessageStore } from './messageStore' import { SUMMARY_ANCHOR_NAMES, + type DeepChatTapeReadSource, type DeepChatTapeEntryRow, type DeepChatTapeSearchInput } from '../sqlitePresenter/tables/deepchatTapeEntries' @@ -77,6 +79,7 @@ export type TapeInfo = { } export type TapeSearchResult = { + sessionId: string entryId: number kind: string name: string | null @@ -149,6 +152,37 @@ const SUBAGENT_TAPE_LINK_OUTCOMES = new Set([ 'cancelled' ]) +type SubagentTapeLinkSnapshot = { + linkEntryId: number + childSessionId: string + childHeadEntryId: number + childEntryCount: number + outcome: SubagentTapeLinkOutcome +} + +type LinkedTapeSourceResolution = { + sources: DeepChatTapeReadSource[] + unavailableSourceIds: Set +} + +export type AgentTapeViewErrorCode = + | 'current_tape_unavailable' + | 'linked_tape_unavailable' + | 'linked_tape_unauthorized' + +export class AgentTapeViewError extends Error { + readonly name = 'AgentTapeViewError' + + constructor( + readonly code: AgentTapeViewErrorCode, + readonly parentSessionId: string, + readonly sourceSessionId: string, + message: string + ) { + super(message) + } +} + export function normalizeSubagentTapeLinkInput( input: SubagentTapeLinkInput ): SubagentTapeLinkInput { @@ -186,7 +220,7 @@ function subagentTapeLinkProvenanceKey(input: SubagentTapeLinkInput): string { return `subagent:tape-link:v1:${identityHash}` } -function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkReceipt { +function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeLinkSnapshot | null { const payload = parseJsonObject(row.payload_json) const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) @@ -215,13 +249,10 @@ function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkR row.source_seq !== childHeadEntryId || childEntryCount > childHeadEntryId ) { - throw new Error(`Stored subagent Tape link receipt is malformed: ${row.entry_id}`) + return null } return { - linkEntry: { - sessionId: row.session_id, - entryId: row.entry_id - }, + linkEntryId: row.entry_id, childSessionId, childHeadEntryId, childEntryCount, @@ -229,6 +260,58 @@ function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkR } } +function parseLegacyExternalTapeLinkSnapshot( + row: DeepChatTapeEntryRow +): SubagentTapeLinkSnapshot | null { + if (row.kind !== 'event' || row.name !== 'fork/merge' || row.source_type !== 'fork') { + return null + } + const payload = parseJsonObject(row.payload_json) + const data = + payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) + ? (payload.data as Record) + : {} + const childSessionId = data.forkSessionId + const forkId = data.forkId + const referencedEntryCount = data.referencedEntryCount + if ( + typeof childSessionId !== 'string' || + !childSessionId || + forkId !== childSessionId || + row.source_id !== childSessionId || + row.provenance_key !== `fork:${row.session_id}:${childSessionId}:external-merge:event` || + typeof referencedEntryCount !== 'number' || + !Number.isSafeInteger(referencedEntryCount) || + referencedEntryCount <= 0 + ) { + return null + } + return { + linkEntryId: row.entry_id, + childSessionId, + childHeadEntryId: referencedEntryCount, + childEntryCount: referencedEntryCount, + outcome: 'completed' + } +} + +function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkReceipt { + const snapshot = parseSubagentTapeLinkSnapshot(row) + if (!snapshot) { + throw new Error(`Stored subagent Tape link receipt is malformed: ${row.entry_id}`) + } + return { + linkEntry: { + sessionId: row.session_id, + entryId: snapshot.linkEntryId + }, + childSessionId: snapshot.childSessionId, + childHeadEntryId: snapshot.childHeadEntryId, + childEntryCount: snapshot.childEntryCount, + outcome: snapshot.outcome + } +} + function assertSubagentTapeLinkReceiptMatchesInput( receipt: SubagentTapeLinkReceipt, input: SubagentTapeLinkInput @@ -692,6 +775,35 @@ function toTapeSearchInput(options: AgentTapeSearchOptions | undefined): DeepCha } } +function normalizeTapeViewScope(scope: AgentTapeViewScope | undefined): AgentTapeViewScope { + if (scope === undefined || scope === 'current') return 'current' + if (scope === 'linked_subagents' || scope === 'current_and_linked') return scope + throw new Error(`Invalid Tape view scope: ${String(scope)}`) +} + +function normalizeTapeSearchLimit(value: number | undefined): number { + if (!Number.isFinite(value)) return 20 + return Math.min(Math.max(Math.floor(value as number), 1), 100) +} + +function compareTapeSearchResults(left: TapeSearchResult, right: TapeSearchResult): number { + const leftHasScore = typeof left.score === 'number' && Number.isFinite(left.score) + const rightHasScore = typeof right.score === 'number' && Number.isFinite(right.score) + if (leftHasScore && rightHasScore && left.score !== right.score) { + return (left.score as number) - (right.score as number) + } + if (leftHasScore !== rightHasScore) { + return leftHasScore ? -1 : 1 + } + if (left.createdAt !== right.createdAt) { + return right.createdAt - left.createdAt + } + if (left.sessionId !== right.sessionId) { + return left.sessionId < right.sessionId ? -1 : 1 + } + return right.entryId - left.entryId +} + function migrationProvenanceKey(sessionId: string): string { return `migration:${sessionId}:message-backfill:v1` } @@ -1109,6 +1221,36 @@ export class DeepChatTapeService implements Pick } search(sessionId: string, query: string, options?: AgentTapeSearchOptions): TapeSearchResult[] { + const scope = normalizeTapeViewScope(options?.scope) + if (!query.trim()) { + return [] + } + if (scope === 'current') { + return this.searchCurrentTape(sessionId, query, options) + } + + const resolution = this.resolveLinkedTapeSources(sessionId) + if (resolution.unavailableSourceIds.size > 0) { + const sourceSessionId = [...resolution.unavailableSourceIds].sort()[0] + throw new AgentTapeViewError( + 'linked_tape_unavailable', + sessionId, + sourceSessionId, + `Linked Tape ${sourceSessionId} is unavailable.` + ) + } + const sources = [...resolution.sources] + if (scope === 'current_and_linked') { + sources.push({ sessionId, maxEntryId: this.table?.getMaxEntryId(sessionId) ?? 0 }) + } + return this.searchTapeSourcesReadOnly(sources, query, options) + } + + private searchCurrentTape( + sessionId: string, + query: string, + options?: AgentTapeSearchOptions + ): TapeSearchResult[] { const table = this.table if (!table) return [] @@ -1163,12 +1305,23 @@ export class DeepChatTapeService implements Pick entryIds: number[], options: AgentTapeContextOptions = {} ): AgentTapeContextResult { + const sourceSessionId = options.sourceSessionId?.trim() || sessionId + if (sourceSessionId !== sessionId) { + return this.getLinkedTapeContext(sessionId, sourceSessionId, entryIds, options) + } + const requestedEntryIds = [ ...new Set(entryIds.filter((entryId) => Number.isInteger(entryId) && entryId > 0)) ].sort((left, right) => left - right) const table = this.table if (!table || requestedEntryIds.length === 0) { - return { sessionId, requestedEntryIds, matchedEntryIds: [], entries: [] } + return { + sessionId, + sourceSessionId, + requestedEntryIds, + matchedEntryIds: [], + entries: [] + } } const rows = table.getBySession(sessionId) @@ -1253,6 +1406,207 @@ export class DeepChatTapeService implements Pick return { sessionId, + sourceSessionId, + requestedEntryIds, + matchedEntryIds: requestedEntryIds.filter((entryId) => returnedEntryIds.has(entryId)), + entries + } + } + + private resolveLinkedTapeSources(parentSessionId: string): LinkedTapeSourceResolution { + const table = this.table + const sessionTable = this.sqlitePresenter.newSessionsTable + if (!table || !sessionTable?.get(parentSessionId)) { + throw new AgentTapeViewError( + 'current_tape_unavailable', + parentSessionId, + parentSessionId, + `Current Tape ${parentSessionId} is unavailable.` + ) + } + + const snapshots = table + .getSubagentLineageEvents(parentSessionId) + .map((row) => parseSubagentTapeLinkSnapshot(row) ?? parseLegacyExternalTapeLinkSnapshot(row)) + .filter((snapshot): snapshot is SubagentTapeLinkSnapshot => snapshot !== null) + const childSessionIds = [...new Set(snapshots.map((snapshot) => snapshot.childSessionId))] + const childById = new Map(sessionTable.getMany(childSessionIds).map((row) => [row.id, row])) + const unavailableSourceIds = new Set() + const maxEntryIdBySource = new Map() + + for (const snapshot of snapshots) { + const child = childById.get(snapshot.childSessionId) + if (!child) { + unavailableSourceIds.add(snapshot.childSessionId) + continue + } + if (child.session_kind !== 'subagent' || child.parent_session_id !== parentSessionId) { + continue + } + maxEntryIdBySource.set( + snapshot.childSessionId, + Math.max(maxEntryIdBySource.get(snapshot.childSessionId) ?? 0, snapshot.childHeadEntryId) + ) + } + + const liveHeads = table.getMaxEntryIdsBySessions([...maxEntryIdBySource.keys()]) + for (const [sourceSessionId, maxEntryId] of maxEntryIdBySource) { + if ((liveHeads.get(sourceSessionId) ?? 0) < maxEntryId) { + maxEntryIdBySource.delete(sourceSessionId) + unavailableSourceIds.add(sourceSessionId) + } + } + + return { + sources: [...maxEntryIdBySource.entries()] + .map(([sessionId, maxEntryId]) => ({ sessionId, maxEntryId })) + .sort((left, right) => + left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0 + ), + unavailableSourceIds + } + } + + private searchTapeSourcesReadOnly( + sources: DeepChatTapeReadSource[], + query: string, + options: AgentTapeSearchOptions | undefined + ): TapeSearchResult[] { + const table = this.table + if (!table || !query.trim() || sources.length === 0) { + return [] + } + const searchInput = toTapeSearchInput(options) + const limit = normalizeTapeSearchLimit(options?.limit) + const results: TapeSearchResult[] = [] + let uncoveredSources = sources + + try { + const projected = this.searchProjectionTable?.searchSourcesReadOnly( + sources, + query, + searchInput + ) + if (projected) { + results.push(...projected.rows.map((row) => this.toProjectedSearchResult(row, undefined))) + const coveredSourceIds = new Set(projected.coveredSources.map((source) => source.sessionId)) + uncoveredSources = sources.filter((source) => !coveredSourceIds.has(source.sessionId)) + } + } catch (error) { + logger.warn( + `[Tape] linked projection search failed; using read-only Tape fallback: ${String(error)}` + ) + uncoveredSources = sources + } + + if (uncoveredSources.length > 0) { + results.push( + ...table + .searchEffectiveSourcesAtHeads(uncoveredSources, query, searchInput) + .map((row) => this.toSearchResult(row)) + ) + } + + const seen = new Set() + return results + .sort(compareTapeSearchResults) + .filter((result) => { + const key = `${result.sessionId}:${result.entryId}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .slice(0, limit) + } + + private getLinkedTapeContext( + parentSessionId: string, + sourceSessionId: string, + entryIds: number[], + options: AgentTapeContextOptions + ): AgentTapeContextResult { + const resolution = this.resolveLinkedTapeSources(parentSessionId) + if (resolution.unavailableSourceIds.has(sourceSessionId)) { + throw new AgentTapeViewError( + 'linked_tape_unavailable', + parentSessionId, + sourceSessionId, + `Linked Tape ${sourceSessionId} is unavailable.` + ) + } + const source = resolution.sources.find((candidate) => candidate.sessionId === sourceSessionId) + if (!source) { + throw new AgentTapeViewError( + 'linked_tape_unauthorized', + parentSessionId, + sourceSessionId, + `Tape ${sourceSessionId} is not an authorized direct child of ${parentSessionId}.` + ) + } + + const requestedEntryIds = [ + ...new Set(entryIds.filter((entryId) => Number.isInteger(entryId) && entryId > 0)) + ].sort((left, right) => left - right) + const table = this.table + if (!table || requestedEntryIds.length === 0) { + return { + sessionId: parentSessionId, + sourceSessionId, + requestedEntryIds, + matchedEntryIds: [], + entries: [] + } + } + + const before = normalizeContextWindowValue(options.before, 2) + const after = normalizeContextWindowValue(options.after, 2) + const limit = normalizeContextLimit(options.limit) + const maxBytesPerEntry = normalizeContextByteLimit( + options.maxBytesPerEntry, + DEFAULT_CONTEXT_MAX_BYTES_PER_ENTRY, + MAX_CONTEXT_MAX_BYTES_PER_ENTRY + ) + const maxTotalBytes = normalizeContextByteLimit( + options.maxTotalBytes, + DEFAULT_CONTEXT_MAX_TOTAL_BYTES, + MAX_CONTEXT_MAX_TOTAL_BYTES + ) + const rows = table.getEffectiveContextRowsAtHead(source, requestedEntryIds, { + before, + after, + limit + }) + let projectionRows = new Map() + try { + projectionRows = new Map( + ( + this.searchProjectionTable?.getByEntryIds( + sourceSessionId, + rows.map((row) => row.entry_id) + ) ?? [] + ).map((row) => [row.entry_id, row]) + ) + } catch { + projectionRows = new Map() + } + + let usedBytes = 0 + const entries: AgentTapeContextEntry[] = [] + for (const row of rows) { + const remaining = Math.max(0, maxTotalBytes - usedBytes) + if (remaining <= 0) break + const maxEntryBytes = Math.min(maxBytesPerEntry, remaining) + const entry = this.toContextEntry(row, projectionRows.get(row.entry_id), maxEntryBytes) + if (entry.evidence.bytes <= 0) continue + usedBytes += entry.evidence.bytes + entries.push(entry) + } + entries.sort((left, right) => left.entryId - right.entryId) + const returnedEntryIds = new Set(entries.map((entry) => entry.entryId)) + + return { + sessionId: parentSessionId, + sourceSessionId, requestedEntryIds, matchedEntryIds: requestedEntryIds.filter((entryId) => returnedEntryIds.has(entryId)), entries @@ -1970,6 +2324,7 @@ export class DeepChatTapeService implements Pick const score = typeof row.score === 'number' && Number.isFinite(row.score) ? row.score : undefined return { + sessionId: row.session_id, entryId: row.entry_id, kind: row.kind, name: row.name, @@ -2011,6 +2366,7 @@ export class DeepChatTapeService implements Pick private toSearchResult(row: DeepChatTapeEntryRow): TapeSearchResult { const projection = this.toProjectionInput(row) return { + sessionId: row.session_id, entryId: row.entry_id, kind: row.kind, name: row.name, diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts index 11a3a5280..c4905e615 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts @@ -55,6 +55,11 @@ export interface DeepChatTapeSearchInput { endCreatedAt?: number } +export interface DeepChatTapeReadSource { + sessionId: string + maxEntryId: number +} + export interface DeepChatTapeMutationProjection { applyAppendedEntry(row: DeepChatTapeEntryRow, previousSessionMaxEntryId: number): boolean invalidateSession(sessionId: string): void @@ -109,6 +114,292 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (character) => `\\${character}`) } +export function normalizeDeepChatTapeReadSources( + sources: readonly DeepChatTapeReadSource[] +): DeepChatTapeReadSource[] { + const maxEntryIdBySession = new Map() + for (const source of sources) { + const sessionId = source.sessionId.trim() + if (!sessionId || !Number.isSafeInteger(source.maxEntryId) || source.maxEntryId < 0) { + continue + } + maxEntryIdBySession.set( + sessionId, + Math.max(maxEntryIdBySession.get(sessionId) ?? 0, source.maxEntryId) + ) + } + return [...maxEntryIdBySession.entries()] + .map(([sessionId, maxEntryId]) => ({ sessionId, maxEntryId })) + .sort((left, right) => + left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0 + ) +} + +export function serializeDeepChatTapeReadSources( + sources: readonly DeepChatTapeReadSource[] +): string { + return JSON.stringify(normalizeDeepChatTapeReadSources(sources)) +} + +const AUTHORIZED_TAPE_SOURCES_CTE_SQL = ` + authorized_sources(session_id, max_entry_id) AS ( + SELECT + json_extract(value, '$.sessionId'), + CAST(json_extract(value, '$.maxEntryId') AS INTEGER) + FROM json_each(?) + ) +` + +function effectiveTapeMessagePredicateSql(alias: string): string { + return ` + json_type(${alias}.payload_json, '$.record') = 'object' + AND typeof(json_extract(${alias}.payload_json, '$.record.id')) = 'text' + AND typeof(json_extract(${alias}.payload_json, '$.record.sessionId')) = 'text' + AND typeof(json_extract(${alias}.payload_json, '$.record.orderSeq')) IN ('integer', 'real') + AND json_extract(${alias}.payload_json, '$.record.role') IN ('user', 'assistant') + AND typeof(json_extract(${alias}.payload_json, '$.record.content')) = 'text' + AND ( + json_extract(${alias}.payload_json, '$.record.status') IS NULL + OR json_extract(${alias}.payload_json, '$.record.status') != 'pending' + ) + ` +} + +function tapeRetractionMessageIdSql(alias: string): string { + return ` + CASE + WHEN json_type(${alias}.payload_json, '$.data') = 'object' + THEN json_extract(${alias}.payload_json, '$.data.messageId') + WHEN json_type(${alias}.payload_json, '$.data') = 'text' + AND json_valid(json_extract(${alias}.payload_json, '$.data')) + THEN json_extract(json_extract(${alias}.payload_json, '$.data'), '$.messageId') + ELSE NULL + END + ` +} + +function tapeToolCallIdSql(alias: string): string { + return ` + CASE + WHEN ${alias}.kind = 'tool_result' + THEN json_extract(${alias}.payload_json, '$.toolCallId') + WHEN json_type(${alias}.payload_json, '$.toolCall') = 'object' + THEN json_extract(${alias}.payload_json, '$.toolCall.id') + WHEN json_type(${alias}.payload_json, '$.toolCall') = 'text' + AND json_valid(json_extract(${alias}.payload_json, '$.toolCall')) + THEN json_extract(json_extract(${alias}.payload_json, '$.toolCall'), '$.id') + ELSE NULL + END + ` +} + +// These read-only SQL forms mirror deepchatTapeEffectiveSemantics. Search uses correlated +// candidate validation to avoid materializing a whole linked Tape; context uses the complete +// effective CTE because it needs stable neighboring-row positions. Native tests cover parity for +// replacement, retraction, head cutoff, and window ordering. +const EFFECTIVE_TAPE_ROWS_CTE_SQL = ` + bounded_rows AS ( + SELECT tape.* + FROM deepchat_tape_entries AS tape + INNER JOIN authorized_sources AS source + ON source.session_id = tape.session_id + AND tape.entry_id <= source.max_entry_id + ), + raw_message_candidates AS ( + SELECT + bounded_rows.*, + json_extract(payload_json, '$.record.id') AS message_id + FROM bounded_rows + WHERE kind = 'message' + ), + message_candidates AS ( + SELECT * + FROM raw_message_candidates + WHERE ${effectiveTapeMessagePredicateSql('raw_message_candidates')} + ), + ranked_messages AS ( + SELECT + message_candidates.*, + ROW_NUMBER() OVER ( + PARTITION BY session_id, message_id + ORDER BY entry_id DESC + ) AS candidate_rank + FROM message_candidates + ), + effective_message_rows AS ( + SELECT ranked_messages.* + FROM ranked_messages + WHERE candidate_rank = 1 + AND NOT EXISTS ( + SELECT 1 + FROM bounded_rows AS retraction + WHERE retraction.session_id = ranked_messages.session_id + AND retraction.kind = 'event' + AND retraction.name = 'message/retracted' + AND retraction.entry_id > ranked_messages.entry_id + AND (${tapeRetractionMessageIdSql('retraction')}) = ranked_messages.message_id + ) + ), + raw_tool_candidates AS ( + SELECT + bounded_rows.*, + json_extract(payload_json, '$.messageId') AS message_id, + (${tapeToolCallIdSql('bounded_rows')}) AS tool_call_id, + json_extract(meta_json, '$.status') AS tool_status + FROM bounded_rows + WHERE kind IN ('tool_call', 'tool_result') + ), + tool_candidates AS ( + SELECT * + FROM raw_tool_candidates + WHERE typeof(message_id) = 'text' + AND length(message_id) > 0 + AND typeof(tool_call_id) = 'text' + AND length(tool_call_id) > 0 + AND tool_status IN ('success', 'error') + ), + ranked_tools AS ( + SELECT + tool_candidates.*, + ROW_NUMBER() OVER ( + PARTITION BY session_id, kind, message_id, tool_call_id + ORDER BY entry_id DESC + ) AS candidate_rank + FROM tool_candidates + ), + effective_rows AS ( + SELECT + session_id, entry_id, kind, name, source_type, source_id, source_seq, + provenance_key, payload_json, meta_json, created_at + FROM bounded_rows + WHERE kind = 'anchor' + UNION ALL + SELECT + session_id, entry_id, kind, name, source_type, source_id, source_seq, + provenance_key, payload_json, meta_json, created_at + FROM bounded_rows + WHERE kind = 'event' + AND (name IS NULL OR name NOT IN ( + 'message/retracted', + 'message/compaction_indicator', + 'migration/backfill' + )) + UNION ALL + SELECT + session_id, entry_id, kind, name, source_type, source_id, source_seq, + provenance_key, payload_json, meta_json, created_at + FROM effective_message_rows + UNION ALL + SELECT + ranked_tools.session_id, + ranked_tools.entry_id, + ranked_tools.kind, + ranked_tools.name, + ranked_tools.source_type, + ranked_tools.source_id, + ranked_tools.source_seq, + ranked_tools.provenance_key, + ranked_tools.payload_json, + ranked_tools.meta_json, + ranked_tools.created_at + FROM ranked_tools + INNER JOIN effective_message_rows AS message + ON message.session_id = ranked_tools.session_id + AND message.message_id = ranked_tools.message_id + WHERE ranked_tools.candidate_rank = 1 + ) +` + +const EFFECTIVE_TAPE_SEARCH_ROW_PREDICATE_SQL = ` + candidate.kind = 'anchor' + OR ( + candidate.kind = 'event' + AND (candidate.name IS NULL OR candidate.name NOT IN ( + 'message/retracted', + 'message/compaction_indicator', + 'migration/backfill' + )) + ) + OR ( + candidate.kind = 'message' + AND ${effectiveTapeMessagePredicateSql('candidate')} + AND NOT EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS later_message + WHERE later_message.session_id = candidate.session_id + AND later_message.entry_id > candidate.entry_id + AND later_message.entry_id <= source.max_entry_id + AND later_message.kind = 'message' + AND ${effectiveTapeMessagePredicateSql('later_message')} + AND json_extract(later_message.payload_json, '$.record.id') = + json_extract(candidate.payload_json, '$.record.id') + ) + AND NOT EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS retraction + WHERE retraction.session_id = candidate.session_id + AND retraction.entry_id > candidate.entry_id + AND retraction.entry_id <= source.max_entry_id + AND retraction.kind = 'event' + AND retraction.name = 'message/retracted' + AND (${tapeRetractionMessageIdSql('retraction')}) = + json_extract(candidate.payload_json, '$.record.id') + ) + ) + OR ( + candidate.kind IN ('tool_call', 'tool_result') + AND json_extract(candidate.meta_json, '$.status') IN ('success', 'error') + AND typeof(json_extract(candidate.payload_json, '$.messageId')) = 'text' + AND length(json_extract(candidate.payload_json, '$.messageId')) > 0 + AND typeof((${tapeToolCallIdSql('candidate')})) = 'text' + AND length((${tapeToolCallIdSql('candidate')})) > 0 + AND NOT EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS later_tool + WHERE later_tool.session_id = candidate.session_id + AND later_tool.entry_id > candidate.entry_id + AND later_tool.entry_id <= source.max_entry_id + AND later_tool.kind = candidate.kind + AND json_extract(later_tool.meta_json, '$.status') IN ('success', 'error') + AND json_extract(later_tool.payload_json, '$.messageId') = + json_extract(candidate.payload_json, '$.messageId') + AND (${tapeToolCallIdSql('later_tool')}) = (${tapeToolCallIdSql('candidate')}) + ) + AND EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS message + WHERE message.session_id = candidate.session_id + AND message.entry_id <= source.max_entry_id + AND message.kind = 'message' + AND ${effectiveTapeMessagePredicateSql('message')} + AND json_extract(message.payload_json, '$.record.id') = + json_extract(candidate.payload_json, '$.messageId') + AND NOT EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS later_message + WHERE later_message.session_id = message.session_id + AND later_message.entry_id > message.entry_id + AND later_message.entry_id <= source.max_entry_id + AND later_message.kind = 'message' + AND ${effectiveTapeMessagePredicateSql('later_message')} + AND json_extract(later_message.payload_json, '$.record.id') = + json_extract(message.payload_json, '$.record.id') + ) + AND NOT EXISTS ( + SELECT 1 + FROM deepchat_tape_entries AS retraction + WHERE retraction.session_id = message.session_id + AND retraction.entry_id > message.entry_id + AND retraction.entry_id <= source.max_entry_id + AND retraction.kind = 'event' + AND retraction.name = 'message/retracted' + AND (${tapeRetractionMessageIdSql('retraction')}) = + json_extract(message.payload_json, '$.record.id') + ) + ) + ) +` + export class DeepChatTapeEntriesTable extends BaseTable { constructor( db: Database.Database, @@ -338,6 +629,19 @@ export class DeepChatTapeEntriesTable extends BaseTable { .all(sessionId) as DeepChatTapeEntryRow[] } + getSubagentLineageEvents(sessionId: string): DeepChatTapeEntryRow[] { + return this.db + .prepare( + `SELECT * + FROM deepchat_tape_entries + WHERE session_id = ? + AND kind = 'event' + AND name IN ('subagent/tape_linked', 'fork/merge') + ORDER BY entry_id ASC` + ) + .all(sessionId) as DeepChatTapeEntryRow[] + } + getBySessionUpToEntryId(sessionId: string, maxEntryId: number): DeepChatTapeEntryRow[] { return this.db .prepare( @@ -509,6 +813,30 @@ export class DeepChatTapeEntriesTable extends BaseTable { return row?.max_entry_id ?? 0 } + getMaxEntryIdsBySessions(sessionIds: string[]): Map { + const ids = [...new Set(sessionIds.map((id) => id.trim()).filter(Boolean))] + const maxEntryIdBySession = new Map(ids.map((id) => [id, 0])) + if (ids.length === 0) { + return maxEntryIdBySession + } + const rows = this.db + .prepare( + `WITH requested_sessions(session_id) AS ( + SELECT value FROM json_each(?) + ) + SELECT tape.session_id, MAX(tape.entry_id) AS max_entry_id + FROM deepchat_tape_entries AS tape + INNER JOIN requested_sessions AS requested + ON requested.session_id = tape.session_id + GROUP BY tape.session_id` + ) + .all(JSON.stringify(ids)) as Array<{ session_id: string; max_entry_id: number }> + for (const row of rows) { + maxEntryIdBySession.set(row.session_id, row.max_entry_id) + } + return maxEntryIdBySession + } + countAnchorsBySession(sessionId: string): number { const row = this.db .prepare( @@ -588,6 +916,148 @@ export class DeepChatTapeEntriesTable extends BaseTable { .all(...params) as DeepChatTapeEntryRow[] } + searchEffectiveSourcesAtHeads( + sources: readonly DeepChatTapeReadSource[], + query: string, + options: DeepChatTapeSearchInput = {} + ): DeepChatTapeEntryRow[] { + const normalizedSources = normalizeDeepChatTapeReadSources(sources) + const normalizedQuery = query.trim() + if (normalizedSources.length === 0 || !normalizedQuery) { + return [] + } + + const limit = Number.isFinite(options.limit) ? (options.limit as number) : 20 + const cappedLimit = Math.min(Math.max(Math.floor(limit), 1), 100) + const whereClauses = [ + "(candidate.payload_json LIKE ? ESCAPE '\\' OR candidate.meta_json LIKE ? ESCAPE '\\' OR candidate.name LIKE ? ESCAPE '\\')" + ] + const queryPattern = `%${escapeLikePattern(normalizedQuery)}%` + const params: Array = [ + serializeDeepChatTapeReadSources(normalizedSources), + queryPattern, + queryPattern, + queryPattern + ] + + if (options.kinds?.length) { + whereClauses.push(`candidate.kind IN (${options.kinds.map(() => '?').join(', ')})`) + params.push(...options.kinds) + } + if (Number.isFinite(options.startCreatedAt)) { + whereClauses.push('candidate.created_at >= ?') + params.push(options.startCreatedAt as number) + } + if (Number.isFinite(options.endCreatedAt)) { + whereClauses.push('candidate.created_at <= ?') + params.push(options.endCreatedAt as number) + } + params.push(cappedLimit) + + return this.db + .prepare( + `WITH + ${AUTHORIZED_TAPE_SOURCES_CTE_SQL} + SELECT candidate.* + FROM deepchat_tape_entries AS candidate + INNER JOIN authorized_sources AS source + ON source.session_id = candidate.session_id + AND candidate.entry_id <= source.max_entry_id + WHERE ${whereClauses.join(' AND ')} + AND (${EFFECTIVE_TAPE_SEARCH_ROW_PREDICATE_SQL}) + ORDER BY candidate.created_at DESC, candidate.session_id ASC, candidate.entry_id DESC + LIMIT ?` + ) + .all(...params) as DeepChatTapeEntryRow[] + } + + getEffectiveContextRowsAtHead( + source: DeepChatTapeReadSource, + entryIds: number[], + options: { before: number; after: number; limit: number } + ): DeepChatTapeEntryRow[] { + const normalizedSource = normalizeDeepChatTapeReadSources([source])[0] + const requestedEntryIds = [ + ...new Set(entryIds.filter((entryId) => Number.isSafeInteger(entryId) && entryId > 0)) + ].sort((left, right) => left - right) + if (!normalizedSource || requestedEntryIds.length === 0) { + return [] + } + const before = Math.min(Math.max(Math.floor(options.before), 0), 20) + const after = Math.min(Math.max(Math.floor(options.after), 0), 20) + const limit = Math.min(Math.max(Math.floor(options.limit), 1), 100) + + return this.db + .prepare( + `WITH + ${AUTHORIZED_TAPE_SOURCES_CTE_SQL}, + ${EFFECTIVE_TAPE_ROWS_CTE_SQL}, + ordered_rows AS ( + SELECT + effective_rows.*, + ROW_NUMBER() OVER (ORDER BY entry_id ASC) AS row_position + FROM effective_rows + ), + requested_ids(entry_id, request_ordinal) AS ( + SELECT CAST(value AS INTEGER), CAST(key AS INTEGER) + FROM json_each(?) + ), + requested_positions AS ( + SELECT + requested_ids.request_ordinal, + ordered_rows.entry_id, + ordered_rows.row_position + FROM requested_ids + INNER JOIN ordered_rows + ON ordered_rows.entry_id = requested_ids.entry_id + ), + context_candidates AS ( + SELECT + ordered_rows.*, + 0 AS priority_group, + requested_positions.request_ordinal, + 0 AS neighbor_position + FROM requested_positions + INNER JOIN ordered_rows + ON ordered_rows.entry_id = requested_positions.entry_id + UNION ALL + SELECT + ordered_rows.*, + 1 AS priority_group, + requested_positions.request_ordinal, + ordered_rows.row_position AS neighbor_position + FROM requested_positions + INNER JOIN ordered_rows + ON ordered_rows.row_position BETWEEN requested_positions.row_position - ? + AND requested_positions.row_position + ? + AND ordered_rows.entry_id != requested_positions.entry_id + ), + ranked_context_candidates AS ( + SELECT + context_candidates.*, + ROW_NUMBER() OVER ( + PARTITION BY session_id, entry_id + ORDER BY priority_group, request_ordinal, neighbor_position + ) AS duplicate_rank + FROM context_candidates + ) + SELECT + session_id, entry_id, kind, name, source_type, source_id, source_seq, + provenance_key, payload_json, meta_json, created_at + FROM ranked_context_candidates + WHERE duplicate_rank = 1 + ORDER BY priority_group, request_ordinal, neighbor_position + LIMIT ?` + ) + .all( + serializeDeepChatTapeReadSources([normalizedSource]), + JSON.stringify(requestedEntryIds), + before, + after, + limit + ) as DeepChatTapeEntryRow[] + } + deleteBySession(sessionId: string): void { const remove = this.db.transaction(() => { this.db.prepare('DELETE FROM deepchat_tape_entries WHERE session_id = ?').run(sessionId) diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts index 545efdb38..66afdb731 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts @@ -1,6 +1,11 @@ import Database from 'better-sqlite3-multiple-ciphers' import { BaseTable } from './baseTable' +import { + normalizeDeepChatTapeReadSources, + serializeDeepChatTapeReadSources +} from './deepchatTapeEntries' import type { + DeepChatTapeReadSource, DeepChatTapeEntryKind, DeepChatTapeSearchInput, DeepChatTapeSourceType @@ -45,6 +50,11 @@ export interface DeepChatTapeSearchProjectionMeta { maxEntryId: number } +export interface DeepChatTapeSearchProjectionReadResult { + rows: DeepChatTapeSearchProjectionResultRow[] + coveredSources: DeepChatTapeReadSource[] +} + type FtsCapability = { available: boolean; tokenizer: 'trigram' | 'unicode61' } const TAPE_SEARCH_PROJECTION_INDEX_SQL = ` @@ -367,6 +377,50 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { .all(sessionId, ...ids) as DeepChatTapeSearchProjectionRow[] } + searchSourcesReadOnly( + sources: readonly DeepChatTapeReadSource[], + query: string, + options: DeepChatTapeSearchInput = {} + ): DeepChatTapeSearchProjectionReadResult { + const normalizedSources = normalizeDeepChatTapeReadSources(sources) + const coveredSources = this.getCurrentReadSources( + normalizedSources, + 'deepchat_tape_search_projection_meta' + ) + const normalizedQuery = query.trim() + if (coveredSources.length === 0 || !normalizedQuery) { + return { rows: [], coveredSources } + } + + const limit = normalizeLimit(options.limit) + const ordered: DeepChatTapeSearchProjectionResultRow[] = [] + const seen = new Set() + const collect = (rows: DeepChatTapeSearchProjectionResultRow[]): void => { + for (const row of rows) { + const key = `${row.session_id}:${row.entry_id}` + if (seen.has(key)) continue + seen.add(key) + ordered.push(row) + } + } + + if (this.ftsReady) { + const ftsSources = this.getCurrentReadSources(coveredSources, 'deepchat_tape_search_fts_meta') + if (ftsSources.length > 0) { + try { + collect(this.searchFtsSourcesReadOnly(ftsSources, normalizedQuery, options, limit)) + } catch { + // The base projection remains a complete read-only fallback. + } + } + } + if (ordered.length < limit) { + collect(this.searchLikeSourcesReadOnly(coveredSources, normalizedQuery, options, limit)) + } + + return { rows: ordered.slice(0, limit), coveredSources } + } + search( sessionId: string, query: string, @@ -717,6 +771,136 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { } } + private getCurrentReadSources( + sources: readonly DeepChatTapeReadSource[], + metaTable: 'deepchat_tape_search_projection_meta' | 'deepchat_tape_search_fts_meta' + ): DeepChatTapeReadSource[] { + const normalizedSources = normalizeDeepChatTapeReadSources(sources) + if (normalizedSources.length === 0) { + return [] + } + const rows = this.db + .prepare( + `WITH requested_sources(session_id, max_entry_id) AS ( + SELECT + json_extract(value, '$.sessionId'), + CAST(json_extract(value, '$.maxEntryId') AS INTEGER) + FROM json_each(?) + ) + SELECT requested.session_id, requested.max_entry_id + FROM requested_sources AS requested + INNER JOIN ${metaTable} AS meta + ON meta.session_id = requested.session_id + AND meta.max_entry_id = requested.max_entry_id + AND meta.projection_version = ? + ORDER BY requested.session_id ASC` + ) + .all( + serializeDeepChatTapeReadSources(normalizedSources), + DEEPCHAT_TAPE_SEARCH_PROJECTION_VERSION + ) as Array<{ session_id: string; max_entry_id: number }> + return rows.map((row) => ({ sessionId: row.session_id, maxEntryId: row.max_entry_id })) + } + + private searchFtsSourcesReadOnly( + sources: readonly DeepChatTapeReadSource[], + normalized: string, + options: DeepChatTapeSearchInput, + limit: number + ): DeepChatTapeSearchProjectionResultRow[] { + const match = buildFtsMatch(normalized) + const whereClauses = ['deepchat_tape_search_fts MATCH ?'] + const params: Array = [serializeDeepChatTapeReadSources(sources), match] + this.addFilters(whereClauses, params, options, true, 'projection') + params.push(limit) + return this.db + .prepare( + `WITH authorized_sources(session_id, max_entry_id) AS ( + SELECT + json_extract(value, '$.sessionId'), + CAST(json_extract(value, '$.maxEntryId') AS INTEGER) + FROM json_each(?) + ) + SELECT + projection.session_id, + projection.entry_id, + projection.kind, + projection.name, + projection.source_type, + projection.source_id, + projection.source_seq, + projection.search_text, + projection.summary_text, + projection.refs_json, + projection.created_at, + bm25(deepchat_tape_search_fts) AS score + FROM deepchat_tape_search_fts + INNER JOIN deepchat_tape_search_projection AS projection + ON projection.session_id = deepchat_tape_search_fts.session_id + AND projection.entry_id = CAST(deepchat_tape_search_fts.entry_id AS INTEGER) + AND projection.search_text = deepchat_tape_search_fts.search_text + INNER JOIN authorized_sources AS source + ON source.session_id = projection.session_id + AND projection.entry_id <= source.max_entry_id + WHERE ${whereClauses.join(' AND ')} + ORDER BY score ASC, projection.created_at DESC, projection.session_id ASC, + projection.entry_id DESC + LIMIT ?` + ) + .all(...params) as DeepChatTapeSearchProjectionResultRow[] + } + + private searchLikeSourcesReadOnly( + sources: readonly DeepChatTapeReadSource[], + normalized: string, + options: DeepChatTapeSearchInput, + limit: number + ): DeepChatTapeSearchProjectionResultRow[] { + const whereClauses: string[] = [] + const pattern = `%${escapeLikePattern(normalized)}%` + const params: Array = [serializeDeepChatTapeReadSources(sources)] + const queryClauses = [ + "(projection.search_text LIKE ? ESCAPE '\\' OR projection.summary_text LIKE ? ESCAPE '\\' OR projection.name LIKE ? ESCAPE '\\')" + ] + params.push(pattern, pattern, pattern) + const tokens = tokenizeQuery(normalized) + if (tokens.length > 1) { + queryClauses.push( + `(${tokens + .map( + () => + "(projection.search_text LIKE ? ESCAPE '\\' OR projection.summary_text LIKE ? ESCAPE '\\' OR projection.name LIKE ? ESCAPE '\\')" + ) + .join(' AND ')})` + ) + for (const token of tokens) { + const tokenPattern = `%${escapeLikePattern(token)}%` + params.push(tokenPattern, tokenPattern, tokenPattern) + } + } + whereClauses.push(`(${queryClauses.join(' OR ')})`) + this.addFilters(whereClauses, params, options, false, 'projection') + params.push(limit) + return this.db + .prepare( + `WITH authorized_sources(session_id, max_entry_id) AS ( + SELECT + json_extract(value, '$.sessionId'), + CAST(json_extract(value, '$.maxEntryId') AS INTEGER) + FROM json_each(?) + ) + SELECT projection.*, NULL AS score + FROM deepchat_tape_search_projection AS projection + INNER JOIN authorized_sources AS source + ON source.session_id = projection.session_id + AND projection.entry_id <= source.max_entry_id + WHERE ${whereClauses.join(' AND ')} + ORDER BY projection.created_at DESC, projection.session_id ASC, projection.entry_id DESC + LIMIT ?` + ) + .all(...params) as DeepChatTapeSearchProjectionResultRow[] + } + private searchLike( sessionId: string, normalized: string, diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 1230199f1..0c651b817 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -47,14 +47,18 @@ export interface AgentTapeInfo { export type AgentTapeEntryKind = 'event' | 'anchor' | 'message' | 'tool_call' | 'tool_result' +export type AgentTapeViewScope = 'current' | 'linked_subagents' | 'current_and_linked' + export interface AgentTapeSearchOptions { limit?: number kinds?: AgentTapeEntryKind[] start?: string end?: string + scope?: AgentTapeViewScope } export interface AgentTapeSearchResult { + sessionId: string entryId: number kind: string name: string | null @@ -88,6 +92,7 @@ export interface AgentTapeContextOptions { limit?: number maxBytesPerEntry?: number maxTotalBytes?: number + sourceSessionId?: string } export interface AgentTapeContextEntry { @@ -106,6 +111,7 @@ export interface AgentTapeContextEntry { export interface AgentTapeContextResult { sessionId: string + sourceSessionId: string requestedEntryIds: number[] matchedEntryIds: number[] entries: AgentTapeContextEntry[] diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index dd99742e5..bb56f43e4 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -10360,7 +10360,45 @@ describe('AgentRuntimePresenter', () => { }) }) - describe('tape handoff', () => { + describe('tape reads and handoff', () => { + it('keeps linked Tape search and context reads free of readiness writes', async () => { + const ensureTapeReady = vi.spyOn((agent as any).tapeService, 'ensureSessionTapeReady') + const search = vi.spyOn((agent as any).tapeService, 'search').mockReturnValue([]) + const getContext = vi.spyOn((agent as any).tapeService, 'getContext').mockReturnValue({ + sessionId: 's1', + sourceSessionId: 'child', + requestedEntryIds: [2], + matchedEntryIds: [], + entries: [] + }) + + await agent.searchTape('s1', 'needle', { scope: 'linked_subagents' }) + await agent.searchTape('s1', 'needle', { scope: 'current_and_linked' }) + await agent.getTapeContext('s1', [2], { sourceSessionId: 'child' }) + + expect(ensureTapeReady).not.toHaveBeenCalled() + expect(search).toHaveBeenCalledWith('s1', 'needle', { scope: 'linked_subagents' }) + expect(search).toHaveBeenCalledWith('s1', 'needle', { scope: 'current_and_linked' }) + expect(getContext).toHaveBeenCalledWith('s1', [2], { sourceSessionId: 'child' }) + }) + + it('preserves readiness backfill for current-only Tape reads', async () => { + const ensureTapeReady = vi.spyOn((agent as any).tapeService, 'ensureSessionTapeReady') + vi.spyOn((agent as any).tapeService, 'search').mockReturnValue([]) + vi.spyOn((agent as any).tapeService, 'getContext').mockReturnValue({ + sessionId: 's1', + sourceSessionId: 's1', + requestedEntryIds: [2], + matchedEntryIds: [], + entries: [] + }) + + await agent.searchTape('s1', 'needle') + await agent.getTapeContext('s1', [2]) + + expect(ensureTapeReady).toHaveBeenCalledTimes(2) + }) + it('rejects an empty summary before preparing or appending Tape state', async () => { const ensureTapeReady = vi.spyOn((agent as any).tapeService, 'ensureSessionTapeReady') const appendHandoff = vi.spyOn((agent as any).tapeService, 'handoff') diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 09d617b92..cb8a25446 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it, vi } from 'vitest' import { buildContext } from '@/presenter/agentRuntimePresenter/contextBuilder' import { toAppSessionId } from '@/agent/shared/agentSessionIds' import { DeepChatTapeService } from '@/presenter/agentRuntimePresenter/tapeService' +import { + buildEffectiveTapeView, + searchEffectiveTapeRows +} from '@/presenter/agentRuntimePresenter/tapeEffectiveView' import { createTapeViewManifest, type TapeViewManifestBuildInput @@ -143,6 +147,14 @@ function createTapeTableMock() { getBySession: vi.fn((sessionId: string) => entries.filter((entry) => entry.session_id === sessionId) ), + getSubagentLineageEvents: vi.fn((sessionId: string) => + entries.filter( + (entry) => + entry.session_id === sessionId && + entry.kind === 'event' && + (entry.name === 'subagent/tape_linked' || entry.name === 'fork/merge') + ) + ), getBySessionUpToEntryId: vi.fn((sessionId: string, maxEntryId: number) => entries.filter((entry) => entry.session_id === sessionId && entry.entry_id <= maxEntryId) ), @@ -152,6 +164,20 @@ function createTapeTableMock() { ...entries.filter((entry) => entry.session_id === sessionId).map((entry) => entry.entry_id) ) ), + getMaxEntryIdsBySessions: vi.fn( + (sessionIds: string[]) => + new Map( + sessionIds.map((sessionId) => [ + sessionId, + Math.max( + 0, + ...entries + .filter((entry) => entry.session_id === sessionId) + .map((entry) => entry.entry_id) + ) + ]) + ) + ), getLatestAnchor: vi.fn( (sessionId: string) => entries @@ -220,6 +246,60 @@ function createTapeTableMock() { .sort((left, right) => right.entry_id - left.entry_id) .slice(0, Math.min(Math.max(limit, 1), 100)) }), + searchEffectiveSourcesAtHeads: vi.fn((sources: any[], query: string, options: any = {}) => + sources + .flatMap((source) => + searchEffectiveTapeRows( + entries.filter( + (entry) => + entry.session_id === source.sessionId && entry.entry_id <= source.maxEntryId + ), + query, + { ...options, limit: 100 } + ) + ) + .sort( + (left, right) => + right.created_at - left.created_at || + left.session_id.localeCompare(right.session_id) || + right.entry_id - left.entry_id + ) + .slice(0, Math.min(Math.max(Math.floor(options.limit ?? 20), 1), 100)) + ), + getEffectiveContextRowsAtHead: vi.fn( + ( + source: any, + entryIds: number[], + options: { before: number; after: number; limit: number } + ) => { + const effectiveRows = buildEffectiveTapeView( + entries.filter( + (entry) => entry.session_id === source.sessionId && entry.entry_id <= source.maxEntryId + ), + { includePending: false } + ).rows + const indexesByEntryId = new Map( + effectiveRows.map((entry, index) => [entry.entry_id, index]) + ) + const indexes: number[] = [] + for (const entryId of entryIds) { + const index = indexesByEntryId.get(entryId) + if (index !== undefined) indexes.push(index) + } + for (const entryId of entryIds) { + const index = indexesByEntryId.get(entryId) + if (index === undefined) continue + for ( + let cursor = Math.max(0, index - options.before); + cursor <= Math.min(effectiveRows.length - 1, index + options.after); + cursor += 1 + ) { + if (cursor !== index) indexes.push(cursor) + } + } + return [...new Set(indexes)].slice(0, options.limit).map((index) => effectiveRows[index]) + } + ), deleteBySession: vi.fn((sessionId: string) => { for (let index = entries.length - 1; index >= 0; index -= 1) { if (entries[index].session_id === sessionId) { @@ -334,6 +414,48 @@ function createTapeService( } as any) } +function createLinkedTapeService( + table: unknown, + sessions: Array<{ + id: string + session_kind: 'regular' | 'subagent' + parent_session_id: string | null + }>, + projectionTable?: unknown +) { + const sessionById = new Map(sessions.map((session) => [session.id, session])) + return { + service: new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatTapeSearchProjectionTable: projectionTable, + newSessionsTable: { + get: vi.fn((sessionId: string) => sessionById.get(sessionId)), + getMany: vi.fn((sessionIds: string[]) => + sessionIds.flatMap((sessionId) => { + const session = sessionById.get(sessionId) + return session ? [session] : [] + }) + ) + }, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any), + sessionById + } +} + +function createSubagentLinkInput(parentSessionId: string, childSessionId: string) { + return { + parentSessionId, + childSessionId, + runId: `run-${childSessionId}`, + taskId: `task-${childSessionId}`, + slotId: 'reviewer', + taskTitle: `Review ${childSessionId}`, + outcome: 'completed' as const, + resultSummary: 'Done' + } +} + function appendObservationIsolationFacts(table: unknown) { const original = createRecord({ id: 'u1', orderSeq: 1, createdAt: 100, updatedAt: 100 }) const edited = createRecord({ @@ -1288,6 +1410,294 @@ describe('DeepChatTapeService', () => { expect(sql).toContain("json_extract(tape.meta_json, '$.messageId') = ?") }) + it('keeps linked raw search candidate-bounded instead of materializing complete Tapes', () => { + const all = vi.fn().mockReturnValue([]) + const db = { + prepare: vi.fn((sql: string) => ({ + all: (...params: unknown[]) => all(sql, params) + })) + } + const table = new DeepChatTapeEntriesTable(db as any) + + table.searchEffectiveSourcesAtHeads( + [ + { sessionId: 'child-b', maxEntryId: 20 }, + { sessionId: 'child-a', maxEntryId: 10 } + ], + 'needle', + { limit: 5 } + ) + + const sql = String(all.mock.calls[0][0]) + const params = all.mock.calls[0][1] + expect(sql).toContain('FROM deepchat_tape_entries AS candidate') + expect(sql).toContain('candidate.entry_id <= source.max_entry_id') + expect(sql).toContain('FROM deepchat_tape_entries AS later_message') + expect(sql).toContain( + "typeof(json_extract(later_message.payload_json, '$.record.content')) = 'text'" + ) + expect(sql).not.toContain('bounded_rows AS') + expect(params[0]).toBe( + JSON.stringify([ + { sessionId: 'child-a', maxEntryId: 10 }, + { sessionId: 'child-b', maxEntryId: 20 } + ]) + ) + expect(params.at(-1)).toBe(5) + }) + + it('reads exact linked projections without invoking FTS recovery or writes', () => { + const run = vi.fn() + const exec = vi.fn() + const reads: Array<{ sql: string; params: unknown[] }> = [] + const prepare = vi.fn((sql: string) => ({ + all: (...params: unknown[]) => { + reads.push({ sql, params }) + if (sql.includes('deepchat_tape_search_projection_meta AS meta')) { + return [{ session_id: 'child', max_entry_id: 2 }] + } + if (sql.includes('SELECT projection.*, NULL AS score')) { + return [ + { + session_id: 'child', + entry_id: 2, + kind: 'event', + name: 'child/result', + source_type: null, + source_id: null, + source_seq: null, + search_text: 'projection needle', + summary_text: 'projection needle', + refs_json: '{}', + created_at: 100, + score: null + } + ] + } + return [] + }, + run + })) + const projectionTable = new DeepChatTapeSearchProjectionTable({ prepare, exec } as any) + + const result = projectionTable.searchSourcesReadOnly( + [{ sessionId: 'child', maxEntryId: 2 }], + 'projection needle', + { limit: 5 } + ) + + expect(result).toMatchObject({ + coveredSources: [{ sessionId: 'child', maxEntryId: 2 }], + rows: [{ session_id: 'child', entry_id: 2 }] + }) + expect(exec).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + expect(prepare.mock.calls.map(([sql]) => String(sql)).join('\n')).not.toContain( + 'deepchat_tape_search_fts_meta' + ) + expect( + reads.find((read) => read.sql.includes('SELECT projection.*, NULL AS score'))?.params.at(-1) + ).toBe(5) + }) + + itIfSqlite( + `queries effective linked sources and context at frozen heads${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, + () => { + const db = new DatabaseCtor(':memory:') + try { + const table = new DeepChatTapeEntriesTable(db) + table.createTable() + table.ensureBootstrapAnchor('child-a') + table.ensureBootstrapAnchor('child-b') + table.appendEvent({ + sessionId: 'child-a', + name: 'child/result', + data: { text: 'native linked needle A' }, + createdAt: 100 + }) + table.appendEvent({ + sessionId: 'child-b', + name: 'child/result', + data: { text: 'native linked needle B' }, + createdAt: 200 + }) + table.appendEvent({ + sessionId: 'child-a', + name: 'child/late', + data: { text: 'native linked needle late' }, + createdAt: 300 + }) + + const sources = [ + { sessionId: 'child-a', maxEntryId: 2 }, + { sessionId: 'child-b', maxEntryId: 2 } + ] + expect( + table.searchEffectiveSourcesAtHeads(sources, 'native linked needle', { limit: 1 }) + ).toMatchObject([{ session_id: 'child-b', entry_id: 2 }]) + expect( + table + .searchEffectiveSourcesAtHeads(sources, 'native linked needle', { limit: 10 }) + .map((row) => [row.session_id, row.entry_id]) + ).toEqual([ + ['child-b', 2], + ['child-a', 2] + ]) + expect( + table + .getEffectiveContextRowsAtHead({ sessionId: 'child-a', maxEntryId: 2 }, [2], { + before: 1, + after: 5, + limit: 10 + }) + .map((row) => row.entry_id) + ).toEqual([2, 1]) + + table.ensureBootstrapAnchor('child-message') + const original = createRecord({ + id: 'linked-message', + sessionId: 'child-message', + content: JSON.stringify({ text: 'old linked marker', files: [], links: [] }) + }) + const replacement = { + ...original, + content: JSON.stringify({ text: 'new linked marker', files: [], links: [] }), + updatedAt: 200 + } + appendMessageRecordToTape(table, original, 'live') + appendMessageReplacementToTape(table, replacement, 'native_edit') + const replacementHead = table.getMaxEntryId('child-message') + expect( + table.searchEffectiveSourcesAtHeads( + [{ sessionId: 'child-message', maxEntryId: replacementHead }], + 'old linked marker' + ) + ).toEqual([]) + const replacementHits = table.searchEffectiveSourcesAtHeads( + [{ sessionId: 'child-message', maxEntryId: replacementHead }], + 'new linked marker' + ) + expect(replacementHits).toHaveLength(1) + expect( + table + .getEffectiveContextRowsAtHead( + { sessionId: 'child-message', maxEntryId: replacementHead }, + [replacementHits[0].entry_id], + { before: 0, after: 0, limit: 5 } + ) + .map((row) => row.entry_id) + ).toEqual([replacementHits[0].entry_id]) + + table.append({ + sessionId: 'child-message', + kind: 'message', + name: 'message/user', + source: { type: 'message', id: original.id, seq: 0 }, + payload: { + record: { + id: original.id, + sessionId: original.sessionId, + orderSeq: original.orderSeq, + role: original.role, + status: 'sent' + } + }, + meta: { source: 'malformed_import' } + }) + expect( + table.searchEffectiveSourcesAtHeads( + [ + { + sessionId: 'child-message', + maxEntryId: table.getMaxEntryId('child-message') + } + ], + 'new linked marker' + ) + ).toMatchObject([{ entry_id: replacementHits[0].entry_id }]) + + appendMessageRetractionToTape(table, replacement, 'native_delete') + expect( + table.searchEffectiveSourcesAtHeads( + [ + { + sessionId: 'child-message', + maxEntryId: table.getMaxEntryId('child-message') + } + ], + 'new linked marker' + ) + ).toEqual([]) + } finally { + db.close() + } + } + ) + + itIfSqlite( + `searches exact frozen projections without repairing a later child tail${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, + () => { + const db = new DatabaseCtor(':memory:') + try { + const projectionTable = new DeepChatTapeSearchProjectionTable(db) + projectionTable.createTable() + projectionTable.replaceSession( + 'child', + [ + { + sessionId: 'child', + entryId: 2, + kind: 'event', + name: 'child/result', + sourceType: null, + sourceId: null, + sourceSeq: null, + searchText: 'native frozen projection needle', + summaryText: 'native frozen projection needle', + refs: {}, + createdAt: 100 + } + ], + 2 + ) + const before = db + .prepare( + `SELECT projection_version, max_entry_id, updated_at + FROM deepchat_tape_search_projection_meta + WHERE session_id = ?` + ) + .get('child') + + const result = projectionTable.searchSourcesReadOnly( + [{ sessionId: 'child', maxEntryId: 2 }], + 'native frozen projection needle', + { limit: 5 } + ) + + expect(result.coveredSources).toEqual([{ sessionId: 'child', maxEntryId: 2 }]) + expect(result.rows).toMatchObject([{ session_id: 'child', entry_id: 2 }]) + expect( + projectionTable.searchSourcesReadOnly( + [{ sessionId: 'child', maxEntryId: 3 }], + 'native frozen projection needle', + { limit: 5 } + ) + ).toEqual({ rows: [], coveredSources: [] }) + expect( + db + .prepare( + `SELECT projection_version, max_entry_id, updated_at + FROM deepchat_tape_search_projection_meta + WHERE session_id = ?` + ) + .get('child') + ).toEqual(before) + } finally { + db.close() + } + } + ) + itIfSqlite( `filters stale FTS rows through the base projection after restart${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, () => { @@ -4164,6 +4574,330 @@ describe('DeepChatTapeService', () => { ).toHaveLength(2) }) + it('searches direct linked children at frozen heads with one global limit', () => { + const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child-a', session_kind: 'subagent', parent_session_id: 'parent' }, + { id: 'child-b', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child-a') + table.ensureBootstrapAnchor('child-b') + table.appendEvent({ + sessionId: 'parent', + name: 'parent/note', + data: { text: 'shared needle from parent' }, + createdAt: 150 + }) + table.appendEvent({ + sessionId: 'child-a', + name: 'child/result', + data: { text: 'shared needle from A' }, + createdAt: 100 + }) + table.appendEvent({ + sessionId: 'child-b', + name: 'child/result', + data: { text: 'shared needle from B' }, + createdAt: 200 + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child-a')) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child-b')) + table.appendEvent({ + sessionId: 'child-a', + name: 'child/late', + data: { text: 'shared needle after cutoff' }, + createdAt: 300 + }) + + table.ensureBootstrapAnchor.mockClear() + table.append.mockClear() + const limited = service.search('parent', 'shared needle', { + scope: 'linked_subagents', + limit: 1 + }) + const all = service.search('parent', 'shared needle', { + scope: 'linked_subagents', + limit: 10 + }) + const combined = service.search('parent', 'shared needle', { + scope: 'current_and_linked', + limit: 10 + }) + + expect(limited).toMatchObject([{ sessionId: 'child-b', entryId: 2 }]) + expect(all.map((result) => [result.sessionId, result.entryId])).toEqual([ + ['child-b', 2], + ['child-a', 2] + ]) + expect(combined.map((result) => [result.sessionId, result.entryId])).toEqual([ + ['child-b', 2], + ['parent', 2], + ['child-a', 2] + ]) + expect(table.searchEffectiveSourcesAtHeads).toHaveBeenCalledWith( + [ + { sessionId: 'child-a', maxEntryId: 2 }, + { sessionId: 'child-b', maxEntryId: 2 } + ], + 'shared needle', + expect.objectContaining({ limit: 1 }) + ) + expect(table.ensureBootstrapAnchor).not.toHaveBeenCalled() + expect(table.append).not.toHaveBeenCalled() + }) + + it('uses an exact frozen projection read-only after the child appends a late tail', () => { + const { table } = createTapeTableMock() + const projectionTable = { + searchSourcesReadOnly: vi.fn((sources: any[]) => ({ + coveredSources: sources, + rows: [ + { + session_id: 'child', + entry_id: 2, + kind: 'event', + name: 'child/result', + source_type: null, + source_id: null, + source_seq: null, + search_text: 'frozen projection needle', + summary_text: 'frozen projection needle', + refs_json: '{}', + created_at: 100, + score: -1 + } + ] + })), + replaceSession: vi.fn(), + appendSession: vi.fn(), + getByEntryIds: vi.fn().mockReturnValue([]) + } + const { service } = createLinkedTapeService( + table, + [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ], + projectionTable + ) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/result', + data: { text: 'frozen projection needle' }, + createdAt: 100 + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + table.appendEvent({ + sessionId: 'child', + name: 'child/late', + data: { text: 'frozen projection needle late' }, + createdAt: 200 + }) + table.searchEffectiveSourcesAtHeads.mockClear() + + const hits = service.search('parent', 'frozen projection needle', { + scope: 'linked_subagents' + }) + + expect(projectionTable.searchSourcesReadOnly).toHaveBeenCalledWith( + [{ sessionId: 'child', maxEntryId: 2 }], + 'frozen projection needle', + expect.any(Object) + ) + expect(hits).toMatchObject([{ sessionId: 'child', entryId: 2 }]) + expect(table.searchEffectiveSourcesAtHeads).not.toHaveBeenCalled() + expect(projectionTable.replaceSession).not.toHaveBeenCalled() + expect(projectionTable.appendSession).not.toHaveBeenCalled() + }) + + it('deduplicates repeated child links at the newest finalized snapshot', () => { + const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/first', + data: { text: 'first snapshot marker' } + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + table.appendEvent({ + sessionId: 'child', + name: 'child/second', + data: { text: 'second snapshot marker' } + }) + service.linkSubagentTape({ + ...createSubagentLinkInput('parent', 'child'), + runId: 'run-child-second', + taskId: 'task-child-second' + }) + + expect( + service.search('parent', 'second snapshot marker', { scope: 'linked_subagents' }) + ).toMatchObject([{ sessionId: 'child', entryId: 3 }]) + expect(table.searchEffectiveSourcesAtHeads).toHaveBeenCalledWith( + [{ sessionId: 'child', maxEntryId: 3 }], + 'second snapshot marker', + expect.any(Object) + ) + }) + + it('expands linked context within one source and never crosses the frozen head', () => { + const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/target', + data: { text: 'target evidence' }, + createdAt: 100 + }) + table.appendEvent({ + sessionId: 'child', + name: 'child/neighbor', + data: { text: 'neighbor evidence' }, + createdAt: 110 + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + table.appendEvent({ + sessionId: 'child', + name: 'child/late', + data: { text: 'late evidence' }, + createdAt: 120 + }) + table.append.mockClear() + + const context = service.getContext('parent', [2], { + sourceSessionId: 'child', + before: 0, + after: 5 + }) + + expect(context).toMatchObject({ + sessionId: 'parent', + sourceSessionId: 'child', + requestedEntryIds: [2], + matchedEntryIds: [2] + }) + expect(context.entries.map((entry) => entry.entryId)).toEqual([2, 3]) + expect(table.getEffectiveContextRowsAtHead).toHaveBeenCalledWith( + { sessionId: 'child', maxEntryId: 3 }, + [2], + expect.objectContaining({ before: 0, after: 5 }) + ) + expect(table.append).not.toHaveBeenCalled() + }) + + it('rejects sibling and unlinked source ids even when a forged link event exists', () => { + const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'other-parent', session_kind: 'regular', parent_session_id: null }, + { id: 'sibling', session_kind: 'subagent', parent_session_id: 'other-parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('sibling') + service.linkSubagentTape(createSubagentLinkInput('parent', 'sibling')) + + let error: unknown + try { + service.getContext('parent', [1], { sourceSessionId: 'sibling' }) + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + code: 'linked_tape_unauthorized', + parentSessionId: 'parent', + sourceSessionId: 'sibling' + }) + }) + + it('reports a finalized linked Tape as unavailable after its durable session is deleted', () => { + const { table } = createTapeTableMock() + const { service, sessionById } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + sessionById.delete('child') + + let error: unknown + try { + service.search('parent', 'anything', { scope: 'linked_subagents' }) + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ + code: 'linked_tape_unavailable', + parentSessionId: 'parent', + sourceSessionId: 'child' + }) + }) + + it('reads legacy external merge links and keeps legacy discard audit-only', () => { + const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'legacy-child', session_kind: 'subagent', parent_session_id: 'parent' }, + { id: 'discarded-child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('legacy-child') + table.ensureBootstrapAnchor('discarded-child') + table.appendEvent({ + sessionId: 'legacy-child', + name: 'child/result', + data: { text: 'legacy link needle' } + }) + table.appendEvent({ + sessionId: 'parent', + name: 'fork/merge', + source: { type: 'fork', id: 'legacy-child', seq: 0 }, + provenanceKey: 'fork:parent:legacy-child:external-merge:event', + data: { + forkId: 'legacy-child', + forkSessionId: 'legacy-child', + referencedEntryCount: 2, + status: 'completed' + } + }) + table.appendEvent({ + sessionId: 'parent', + name: 'fork/discard', + source: { type: 'fork', id: 'discarded-child', seq: 0 }, + provenanceKey: 'fork:parent:discarded-child:external-discard:event', + data: { + forkId: 'discarded-child', + forkSessionId: 'discarded-child', + status: 'cancelled' + } + }) + + expect( + service.search('parent', 'legacy link needle', { scope: 'linked_subagents' }) + ).toMatchObject([{ sessionId: 'legacy-child', entryId: 2 }]) + let error: unknown + try { + service.getContext('parent', [1], { sourceSessionId: 'discarded-child' }) + } catch (caught) { + error = caught + } + expect(error).toMatchObject({ code: 'linked_tape_unauthorized' }) + }) + it('uses effective message facts after replacement and retraction events', () => { const { table, entries } = createTapeTableMock() const original = createRecord({ id: 'u1', orderSeq: 1 }) From 3c28a48301268ece9cedf4f24bc3a6d8102378e4 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:31:37 +0800 Subject: [PATCH 05/17] feat(tools): add cross-tape recall --- .../agentTools/agentTapeTools.ts | 50 +++---- src/main/presenter/toolPresenter/index.ts | 4 +- .../contracts/routes/sessions.routes.ts | 3 +- .../manager/directAcpAgentBackend.test.ts | 7 +- .../projectionCoordinator.test.ts | 10 +- .../agentTools/agentTapeTools.test.ts | 129 ++++++++++++++++++ .../toolPresenter/toolPresenter.test.ts | 8 +- test/main/routes/dispatcher.test.ts | 42 ++++++ 8 files changed, 221 insertions(+), 32 deletions(-) diff --git a/src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts b/src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts index 9f8d54c1d..7bdee3f56 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts @@ -3,12 +3,14 @@ import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' import type { MCPToolDefinition } from '@shared/presenter' import { createAgentToolSuccessResult } from '@shared/lib/agentToolResultEnvelope' import { TAPE_TOOL_NAMES, getAgentToolExposure, isTapeToolName } from '@shared/agentTools' +import type { AgentTapeSearchResult } from '@shared/types/agent-interface' import type { AgentToolRuntimePort } from '../runtimePorts' import type { AgentToolCallResult } from './agentToolManager' export const AGENT_TAPE_TOOL_SERVER_NAME = 'agent-tape' const tapeEntryKindSchema = z.enum(['event', 'anchor', 'message', 'tool_call', 'tool_result']) +const tapeViewScopeSchema = z.enum(['current', 'linked_subagents', 'current_and_linked']) function isTapeSearchBoundary(value: string): boolean { const trimmed = value.trim() @@ -16,7 +18,7 @@ function isTapeSearchBoundary(value: string): boolean { } const tapeSearchSchema = z.object({ - query: z.string().trim().min(1).describe('Text to search within this session tape.'), + query: z.string().trim().min(1).describe('Text to search within the selected Tape view.'), limit: z .number() .int() @@ -41,7 +43,12 @@ const tapeSearchSchema = z.object({ .min(1) .refine(isTapeSearchBoundary, 'Expected an ISO date/time or millisecond timestamp.') .optional() - .describe('Optional inclusive ISO date/time or millisecond timestamp upper bound.') + .describe('Optional inclusive ISO date/time or millisecond timestamp upper bound.'), + scope: tapeViewScopeSchema + .optional() + .describe( + 'Tape sources to search. Defaults to current; linked scopes include only finalized direct subagent Tapes.' + ) }) const tapeContextSchema = z.object({ @@ -88,7 +95,15 @@ const tapeContextSchema = z.object({ .min(0) .max(65536) .optional() - .describe('Maximum evidence bytes across all returned entries. Defaults to 16384.') + .describe('Maximum evidence bytes across all returned entries. Defaults to 16384.'), + sourceSessionId: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Source Tape sessionId from tape_search. Omit for the current session; linked sources must be finalized direct children.' + ) }) const tapeToolSchemas = { @@ -145,24 +160,9 @@ function createTapeResult( } } -function toTapeSearchOverview(result: { - entryId: number - kind: string - name: string | null - createdAt: number - summary?: string - refs?: Record - score?: number -}): { - entryId: number - kind: string - name: string | null - createdAt: number - summary?: string - refs?: Record - score?: number -} { +function toTapeSearchOverview(result: AgentTapeSearchResult): AgentTapeSearchResult { return { + sessionId: result.sessionId, entryId: result.entryId, kind: result.kind, name: result.name, @@ -202,12 +202,12 @@ export class AgentTapeToolHandler { return [ buildToolDefinition( TAPE_TOOL_NAMES.search, - 'Search this DeepChat-scoped append-only tape subset inspired by bub tape.search. Supports text query plus optional kind and created-at filters for the current session.', + 'Search the current DeepChat Tape or finalized direct subagent Tapes. Results are compact, source-qualified, and bounded by each linked Tape snapshot.', tapeSearchSchema ), buildToolDefinition( TAPE_TOOL_NAMES.context, - 'Expand compact local evidence around selected tape entry IDs for the current session without returning unbounded raw payloads.', + 'Expand compact local evidence around selected Tape entry IDs within exactly one current or linked source without returning unbounded raw payloads.', tapeContextSchema ) ] @@ -238,7 +238,8 @@ export class AgentTapeToolHandler { limit: args.limit, kinds: args.kinds, start: args.start, - end: args.end + end: args.end, + ...(args.scope === undefined ? {} : { scope: args.scope }) }) const overview = results.map(toTapeSearchOverview) return createTapeResult(toolName, overview, `Found ${overview.length} tape entries.`) @@ -253,7 +254,8 @@ export class AgentTapeToolHandler { after: args.after, limit: args.limit, maxBytesPerEntry: args.maxBytesPerEntry, - maxTotalBytes: args.maxTotalBytes + maxTotalBytes: args.maxTotalBytes, + ...(args.sourceSessionId === undefined ? {} : { sourceSessionId: args.sourceSessionId }) }) return createTapeResult( toolName, diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 5e53c1d81..62874308e 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -732,12 +732,12 @@ export class ToolPresenter implements IToolPresenter { if (toolNames.has(TAPE_TOOL_NAMES.search)) { lines.push( - '`tape_search` supports `query`, `limit`, `kinds`, `start`, and `end` for scoped canonical tape lookup.' + '`tape_search` supports `query`, `limit`, `kinds`, `start`, `end`, and `scope`; each result includes its source `sessionId`.' ) } if (toolNames.has(TAPE_TOOL_NAMES.context)) { lines.push( - '`tape_context` expands selected `entryIds` from compact `tape_search` results into bounded evidence/context without dumping raw payloads.' + '`tape_context` expands selected `entryIds` from exactly one source into bounded evidence/context without dumping raw payloads; pass the result `sessionId` as `sourceSessionId` for linked Tapes and omit it for the current Tape.' ) } return lines.join('\n') diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index 673802c19..b655a7ba4 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -367,7 +367,8 @@ export const sessionsGetTapeContextRoute = defineRouteContract({ after: z.number().int().min(0).max(20).optional(), limit: z.number().int().positive().max(100).optional(), maxBytesPerEntry: z.number().int().min(0).max(8192).optional(), - maxTotalBytes: z.number().int().min(0).max(65536).optional() + maxTotalBytes: z.number().int().min(0).max(65536).optional(), + sourceSessionId: z.string().trim().min(1).optional() }) .optional() }), diff --git a/test/main/agent/manager/directAcpAgentBackend.test.ts b/test/main/agent/manager/directAcpAgentBackend.test.ts index 0b87956f1..97111b2a7 100644 --- a/test/main/agent/manager/directAcpAgentBackend.test.ts +++ b/test/main/agent/manager/directAcpAgentBackend.test.ts @@ -283,7 +283,12 @@ describe('direct ACP agent backend', () => { } await expect(harness.backend.transferSource.hasMessages(sessionId)).resolves.toBe(true) - await harness.backend.subagent.linkTape(linkInput) + await expect(harness.backend.subagent.linkTape(linkInput)).resolves.toMatchObject({ + linkEntry: { sessionId, entryId: 1 }, + childSessionId: childId, + childHeadEntryId: 2, + outcome: 'completed' + }) expect(harness.backend.generationControl.getActiveGeneration(sessionId)).toEqual({ eventId: 'message', runId: 'request' diff --git a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts index cd6d6835b..7dad0a62f 100644 --- a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts @@ -254,8 +254,8 @@ describe('SessionProjectionCoordinator', () => { await expect(harness.coordinator.getMessageIds('s1')).resolves.toEqual(['m1']) await expect(harness.coordinator.getMessage('m1')).resolves.toBe(message) await expect(harness.coordinator.getTapeInfo('s1')).resolves.toEqual({ sessionId: 's1' }) - await harness.coordinator.searchTape('s1', 'needle') - await harness.coordinator.getTapeContext('s1', [1]) + await harness.coordinator.searchTape('s1', 'needle', { scope: 'current_and_linked' }) + await harness.coordinator.getTapeContext('s1', [1], { sourceSessionId: 'acp-child' }) await harness.coordinator.listTapeAnchors('s1') await harness.coordinator.handoffTape('s1', 'handoff', { summary: 'handoff summary' }) await expect(harness.coordinator.listMessageViewManifests(' m1 ')).resolves.toEqual([ @@ -278,6 +278,12 @@ describe('SessionProjectionCoordinator', () => { '[SessionProjectionCoordinator] Failed to parse search result row:', expect.any(SyntaxError) ) + expect(harness.tape.searchTape).toHaveBeenCalledWith('s1', 'needle', { + scope: 'current_and_linked' + }) + expect(harness.tape.getTapeContext).toHaveBeenCalledWith('s1', [1], { + sourceSessionId: 'acp-child' + }) warn.mockRestore() }) diff --git a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts index b44a74a46..17e4f8a63 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts @@ -46,6 +46,7 @@ const buildRuntimePort = (overrides: Record = {}) => }), searchTape: vi.fn().mockResolvedValue([ { + sessionId: 'conv-1', entryId: 2, kind: 'message', name: 'user/message', @@ -58,6 +59,7 @@ const buildRuntimePort = (overrides: Record = {}) => ]), getTapeContext: vi.fn().mockResolvedValue({ sessionId: 'conv-1', + sourceSessionId: 'conv-1', requestedEntryIds: [2], matchedEntryIds: [2], entries: [ @@ -159,6 +161,30 @@ describe('Agent tape tools', () => { expect(tapeNames).toEqual([TAPE_TOOL_NAMES.search, TAPE_TOOL_NAMES.context]) }) + it('describes source-qualified linked recall in both tool schemas', async () => { + const manager = buildManager() + + const defs = await manager.getAllToolDefinitions({ + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: '/workspace', + conversationId: 'conv-1' + }) + const search = defs.find((def) => def.function.name === TAPE_TOOL_NAMES.search) + const context = defs.find((def) => def.function.name === TAPE_TOOL_NAMES.context) + + expect(search?.function.parameters).toMatchObject({ + properties: { + scope: { enum: ['current', 'linked_subagents', 'current_and_linked'] } + } + }) + expect(context?.function.parameters).toMatchObject({ + properties: { + sourceSessionId: { type: 'string' } + } + }) + }) + it('exposes neither recall tool when compact context is unsupported', async () => { const manager = buildManager(buildRuntimePort({ getTapeContext: undefined })) @@ -271,10 +297,12 @@ describe('Agent tape tools', () => { content: string } expect(JSON.parse(search.content)).toHaveLength(1) + expect(JSON.parse(search.content)[0]).toMatchObject({ sessionId: 'conv-1', entryId: 2 }) expect(JSON.parse(search.content)[0]).not.toHaveProperty('payload') expect(JSON.parse(search.content)[0]).not.toHaveProperty('meta') expect(JSON.parse(context.content)).toMatchObject({ sessionId: 'conv-1', + sourceSessionId: 'conv-1', entries: [ { entryId: 2, @@ -302,6 +330,107 @@ describe('Agent tape tools', () => { expect(runtimePort.handoffTape).not.toHaveBeenCalled() }) + it('recalls a finalized ACP child through source-qualified linked Tape options', async () => { + const runtimePort = buildRuntimePort({ + searchTape: vi.fn().mockResolvedValue([ + { + sessionId: 'acp-child', + entryId: 7, + kind: 'tool_result', + name: 'shell', + createdAt: 20, + summary: 'ACP child result' + } + ]), + getTapeContext: vi.fn().mockResolvedValue({ + sessionId: 'conv-1', + sourceSessionId: 'acp-child', + requestedEntryIds: [7], + matchedEntryIds: [7], + entries: [] + }) + }) + const manager = buildManager(runtimePort) + + const search = (await manager.callTool( + TAPE_TOOL_NAMES.search, + { query: 'result', scope: 'linked_subagents' }, + 'conv-1' + )) as { content: string } + const context = (await manager.callTool( + TAPE_TOOL_NAMES.context, + { entryIds: [7], sourceSessionId: 'acp-child' }, + 'conv-1' + )) as { content: string } + + expect(JSON.parse(search.content)).toMatchObject([ + { sessionId: 'acp-child', entryId: 7, summary: 'ACP child result' } + ]) + expect(JSON.parse(context.content)).toMatchObject({ + sessionId: 'conv-1', + sourceSessionId: 'acp-child' + }) + expect(runtimePort.searchTape).toHaveBeenCalledWith('conv-1', 'result', { + limit: undefined, + kinds: undefined, + start: undefined, + end: undefined, + scope: 'linked_subagents' + }) + expect(runtimePort.getTapeContext).toHaveBeenCalledWith('conv-1', [7], { + before: undefined, + after: undefined, + limit: undefined, + maxBytesPerEntry: undefined, + maxTotalBytes: undefined, + sourceSessionId: 'acp-child' + }) + expect(runtimePort.getTapeInfo).not.toHaveBeenCalled() + expect(runtimePort.listTapeAnchors).not.toHaveBeenCalled() + expect(runtimePort.handoffTape).not.toHaveBeenCalled() + }) + + it('rejects invalid cross-Tape selectors before calling the runtime', async () => { + const runtimePort = buildRuntimePort() + const manager = buildManager(runtimePort) + + await expect( + manager.callTool( + TAPE_TOOL_NAMES.search, + { query: 'needle', scope: 'recursive_descendants' }, + 'conv-1' + ) + ).rejects.toThrow() + await expect( + manager.callTool(TAPE_TOOL_NAMES.context, { entryIds: [1], sourceSessionId: ' ' }, 'conv-1') + ).rejects.toThrow() + + expect(runtimePort.searchTape).not.toHaveBeenCalled() + expect(runtimePort.getTapeContext).not.toHaveBeenCalled() + }) + + it('preserves explicit linked Tape availability diagnostics', async () => { + const unavailable = Object.assign(new Error('Linked Tape acp-child is unavailable.'), { + code: 'linked_tape_unavailable', + sourceSessionId: 'acp-child' + }) + const runtimePort = buildRuntimePort({ + searchTape: vi.fn().mockRejectedValue(unavailable) + }) + const manager = buildManager(runtimePort) + + await expect( + manager.callTool( + TAPE_TOOL_NAMES.search, + { query: 'result', scope: 'linked_subagents' }, + 'conv-1' + ) + ).rejects.toMatchObject({ + code: 'linked_tape_unavailable', + sourceSessionId: 'acp-child' + }) + }) + it.each([TAPE_TOOL_NAMES.info, TAPE_TOOL_NAMES.anchors, TAPE_TOOL_NAMES.handoff])( 'rejects non-model Tape call %s without runtime side effects', async (toolName) => { diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index 4ee8057ff..d7bd0fa6c 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -1262,7 +1262,7 @@ describe('ToolPresenter', () => { expect(prompt).not.toContain('tape_handoff') }) - it('describes tape_context only when the context tool is enabled', () => { + it('describes source-qualified Tape recall when the tool pair is enabled', () => { const mcpPresenter = { getAllToolDefinitions: vi.fn().mockResolvedValue([]), callTool: vi.fn() @@ -1294,8 +1294,12 @@ describe('ToolPresenter', () => { ] }) + expect(prompt).toContain('`tape_search` supports `query`, `limit`, `kinds`, `start`, `end`') + expect(prompt).toContain('`scope`') + expect(prompt).toContain('source `sessionId`') expect(prompt).toContain('`tape_context` expands selected `entryIds`') - expect(prompt).toContain('compact `tape_search` results') + expect(prompt).toContain('from exactly one source') + expect(prompt).toContain('`sourceSessionId` for linked Tapes') expect(prompt).toContain('bounded evidence/context') expect(prompt).toContain('without dumping raw payloads') }) diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index a8e91f980..1bb19d8cd 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -3828,6 +3828,7 @@ describe('dispatchDeepchatRoute', () => { it('dispatches moved session read routes through explicit owners', async () => { const { runtime, + sessionProjectionPort, sessionHistorySearch, sessionTranslation, agentSessionExportService, @@ -3853,14 +3854,55 @@ describe('dispatchDeepchatRoute', () => { { sessionId: 'session-1', format: 'markdown' }, context ) + sessionProjectionPort.getTapeContext.mockResolvedValueOnce({ + sessionId: 'session-1', + sourceSessionId: 'acp-child', + requestedEntryIds: [7], + matchedEntryIds: [7], + entries: [] + }) + const tapeContext = await dispatchDeepchatRoute( + runtime, + 'sessions.getTapeContext', + { + sessionId: 'session-1', + entryIds: [7], + options: { before: 1, sourceSessionId: 'acp-child' } + }, + context + ) const agents = await dispatchDeepchatRoute(runtime, 'sessions.getAgents', {}, context) expect(sessionHistorySearch.search).toHaveBeenCalledWith('release', { limit: 5 }) expect(sessionTranslation.translate).toHaveBeenCalledWith('hello', 'fr-FR', 'deepchat') expect(agentSessionExportService.export).toHaveBeenCalledWith('session-1', 'markdown') + expect(sessionProjectionPort.getTapeContext).toHaveBeenCalledWith('session-1', [7], { + before: 1, + sourceSessionId: 'acp-child' + }) + expect(tapeContext).toEqual({ + context: expect.objectContaining({ + sessionId: 'session-1', + sourceSessionId: 'acp-child' + }) + }) expect(configPresenter.listAgents).toHaveBeenCalled() expect(configPresenter.getAcpEnabled).toHaveBeenCalled() expect(agents).toEqual({ agents: [expect.objectContaining({ id: 'deepchat' })] }) + + await expect( + dispatchDeepchatRoute( + runtime, + 'sessions.getTapeContext', + { + sessionId: 'session-1', + entryIds: [7], + options: { sourceSessionId: ' ' } + }, + context + ) + ).rejects.toThrow() + expect(sessionProjectionPort.getTapeContext).toHaveBeenCalledTimes(1) }) it('dispatches provider query and tool interaction routes through typed services', async () => { From 439bcc85d8f2341481a848b543b0ea4b6d065e21 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:34:40 +0800 Subject: [PATCH 06/17] fix(tape): address lineage review findings --- .../agentRuntimePresenter/tapeService.ts | 54 +++++++++++++++---- .../agentRuntimePresenter/tapeService.test.ts | 44 +++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index 7c55ec2f1..4c27c60e9 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -295,6 +295,45 @@ function parseLegacyExternalTapeLinkSnapshot( } } +function readForkMergeReceiptCount( + row: DeepChatTapeEntryRow, + parentSessionId: string, + forkId: string, + forkSessionIdValue: string +): number { + const payload = parseJsonObject(row.payload_json) + const data = + payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) + ? (payload.data as Record) + : {} + const mergedCount = data.mergedCount + const forkHeadEntryId = data.forkHeadEntryId + const hasValidLegacyOrCurrentHead = + forkHeadEntryId === undefined || + (typeof forkHeadEntryId === 'number' && + Number.isSafeInteger(forkHeadEntryId) && + forkHeadEntryId >= 0) + if ( + row.session_id !== parentSessionId || + row.kind !== 'event' || + row.name !== 'fork/merge' || + row.source_type !== 'fork' || + row.source_id !== forkId || + row.source_seq !== 0 || + row.provenance_key !== `fork:${parentSessionId}:${forkId}:merge:event` || + data.forkId !== forkId || + data.forkSessionId !== forkSessionIdValue || + typeof mergedCount !== 'number' || + !Number.isSafeInteger(mergedCount) || + mergedCount < 0 || + !hasValidLegacyOrCurrentHead || + (typeof forkHeadEntryId === 'number' && mergedCount > forkHeadEntryId) + ) { + throw new Error(`Stored fork merge receipt is malformed: ${row.entry_id}`) + } + return mergedCount +} + function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkReceipt { const snapshot = parseSubagentTapeLinkSnapshot(row) if (!snapshot) { @@ -2050,15 +2089,12 @@ export class DeepChatTapeService implements Pick return table.runInTransaction(() => { const existingReceipt = table.getByProvenanceKey(parentSessionId, mergeProvenanceKey) if (existingReceipt) { - const payload = parseJsonObject(existingReceipt.payload_json) - const data = - payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) - ? (payload.data as Record) - : {} - const mergedCount = data.mergedCount - return typeof mergedCount === 'number' && Number.isSafeInteger(mergedCount) - ? Math.max(0, mergedCount) - : 0 + return readForkMergeReceiptCount( + existingReceipt, + parentSessionId, + forkId, + forkSessionIdValue + ) } const forkHeadEntryId = table.getMaxEntryId(forkSessionIdValue) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index cb8a25446..c11f36f89 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -4434,6 +4434,50 @@ describe('DeepChatTapeService', () => { }) }) + it('accepts a valid legacy fork merge receipt without a frozen head', () => { + const { table } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + table.appendEvent({ + sessionId: 'parent', + name: 'fork/merge', + source: { type: 'fork', id: 'legacy-receipt', seq: 0 }, + provenanceKey: 'fork:parent:legacy-receipt:merge:event', + data: { + forkId: 'legacy-receipt', + forkSessionId: 'parent::fork::legacy-receipt', + mergedCount: 2 + } + }) + + expect(service.mergeFork('parent', 'legacy-receipt')).toBe(2) + }) + + it('rejects a malformed stored fork merge receipt instead of reporting an empty merge', () => { + const { table } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + table.appendEvent({ + sessionId: 'parent', + name: 'fork/merge', + source: { type: 'fork', id: 'malformed-receipt', seq: 0 }, + provenanceKey: 'fork:parent:malformed-receipt:merge:event', + data: { + forkId: 'malformed-receipt', + forkSessionId: 'parent::fork::malformed-receipt', + mergedCount: 'two' + } + }) + + expect(() => service.mergeFork('parent', 'malformed-receipt')).toThrow( + 'Stored fork merge receipt is malformed' + ) + }) + itIfSqlite('rolls back copied fork entries and the receipt when merge fails', () => { const db = new DatabaseCtor(':memory:') const table = new DeepChatTapeEntriesTable(db) From 746907abf993c1fa563bf592b52ddef2aabc8c7f Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:39:08 +0800 Subject: [PATCH 07/17] test(tape): fix native linked view fixture --- test/main/presenter/agentRuntimePresenter/tapeService.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index c11f36f89..748db4739 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -1593,6 +1593,7 @@ describe('DeepChatTapeService', () => { kind: 'message', name: 'message/user', source: { type: 'message', id: original.id, seq: 0 }, + provenanceKey: null, payload: { record: { id: original.id, From b26f2aad38190c99f657b717003b86a865e84b00 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:45:37 +0800 Subject: [PATCH 08/17] fix(tape): validate subagent link receipts --- .../agentRuntimePresenter/tapeService.ts | 39 +++++++++++++++++++ .../agentRuntimePresenter/tapeService.test.ts | 33 ++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index 4c27c60e9..1cc7f60eb 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -230,6 +230,11 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL const childHeadEntryId = data.childHeadEntryId const childEntryCount = data.childEntryCount const outcome = data.outcome + const runId = data.runId + const taskId = data.taskId + const slotId = data.slotId + const taskTitle = data.taskTitle + const resultSummary = data.resultSummary if ( row.kind !== 'event' || row.name !== SUBAGENT_TAPE_LINK_EVENT_NAME || @@ -243,6 +248,11 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL childEntryCount < 0 || typeof outcome !== 'string' || !SUBAGENT_TAPE_LINK_OUTCOMES.has(outcome as SubagentTapeLinkOutcome) || + typeof runId !== 'string' || + typeof taskId !== 'string' || + typeof slotId !== 'string' || + typeof taskTitle !== 'string' || + (resultSummary !== null && typeof resultSummary !== 'string') || data.linkVersion !== 1 || row.source_type !== 'subagent' || row.source_id !== childSessionId || @@ -251,6 +261,35 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL ) { return null } + + let normalizedInput: SubagentTapeLinkInput + try { + normalizedInput = normalizeSubagentTapeLinkInput({ + parentSessionId: row.session_id, + childSessionId, + runId, + taskId, + slotId, + taskTitle, + outcome: outcome as SubagentTapeLinkOutcome, + resultSummary + }) + } catch { + return null + } + if ( + normalizedInput.parentSessionId !== row.session_id || + normalizedInput.childSessionId !== childSessionId || + normalizedInput.runId !== runId || + normalizedInput.taskId !== taskId || + normalizedInput.slotId !== slotId || + normalizedInput.taskTitle !== taskTitle || + normalizedInput.resultSummary !== resultSummary || + row.provenance_key !== subagentTapeLinkProvenanceKey(normalizedInput) + ) { + return null + } + return { linkEntryId: row.entry_id, childSessionId, diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 748db4739..b59e1bec9 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -4619,6 +4619,39 @@ describe('DeepChatTapeService', () => { ).toHaveLength(2) }) + it('rejects a stored subagent link whose task identity no longer matches its provenance', () => { + const { table, entries } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + const input = createSubagentLinkInput('parent', 'child') + service.linkSubagentTape(input) + + const link = entries.find( + (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' + ) + if (!link) { + throw new Error('Expected subagent Tape link fixture.') + } + const payload = JSON.parse(link.payload_json) as { + data?: Record + } + link.payload_json = JSON.stringify({ + ...payload, + data: { ...payload.data, runId: 'different-run' } + }) + + expect(() => service.linkSubagentTape(input)).toThrow( + /Stored subagent Tape link receipt is malformed/ + ) + expect(() => service.getContext('parent', [1], { sourceSessionId: 'child' })).toThrowError( + /not an authorized direct child/ + ) + }) + it('searches direct linked children at frozen heads with one global limit', () => { const { table } = createTapeTableMock() const { service } = createLinkedTapeService(table, [ From d4bb7aba568ee432c69b50ad6e41d8965497cdad Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 15 Jul 2026 23:51:16 +0800 Subject: [PATCH 09/17] fix(tape): harden lineage boundaries --- .../agentRuntimePresenter/tapeService.ts | 1 + .../agentTools/subagentOrchestratorTool.ts | 2 +- .../agentRuntimePresenter/tapeService.test.ts | 22 +++++++++++++++ .../subagentOrchestratorTool.test.ts | 28 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index 1cc7f60eb..acc0dde2e 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -318,6 +318,7 @@ function parseLegacyExternalTapeLinkSnapshot( !childSessionId || forkId !== childSessionId || row.source_id !== childSessionId || + row.source_seq !== 0 || row.provenance_key !== `fork:${row.session_id}:${childSessionId}:external-merge:event` || typeof referencedEntryCount !== 'number' || !Number.isSafeInteger(referencedEntryCount) || diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 465c0e56e..8c460804b 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -565,7 +565,7 @@ export class SubagentOrchestratorTool { } const childSessionId = task.sessionId - const linkSubagentTape = this.runtimePort.linkSubagentTape + const linkSubagentTape = this.runtimePort.linkSubagentTape?.bind(this.runtimePort) if (!linkSubagentTape) { task.tapeFinalizeError = 'Subagent Tape link capability is unavailable.' return diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index b59e1bec9..a49272617 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -4930,10 +4930,12 @@ describe('DeepChatTapeService', () => { const { service } = createLinkedTapeService(table, [ { id: 'parent', session_kind: 'regular', parent_session_id: null }, { id: 'legacy-child', session_kind: 'subagent', parent_session_id: 'parent' }, + { id: 'malformed-child', session_kind: 'subagent', parent_session_id: 'parent' }, { id: 'discarded-child', session_kind: 'subagent', parent_session_id: 'parent' } ]) table.ensureBootstrapAnchor('parent') table.ensureBootstrapAnchor('legacy-child') + table.ensureBootstrapAnchor('malformed-child') table.ensureBootstrapAnchor('discarded-child') table.appendEvent({ sessionId: 'legacy-child', @@ -4952,6 +4954,23 @@ describe('DeepChatTapeService', () => { status: 'completed' } }) + table.appendEvent({ + sessionId: 'malformed-child', + name: 'child/result', + data: { text: 'malformed legacy link needle' } + }) + table.appendEvent({ + sessionId: 'parent', + name: 'fork/merge', + source: { type: 'fork', id: 'malformed-child', seq: 1 }, + provenanceKey: 'fork:parent:malformed-child:external-merge:event', + data: { + forkId: 'malformed-child', + forkSessionId: 'malformed-child', + referencedEntryCount: 2, + status: 'completed' + } + }) table.appendEvent({ sessionId: 'parent', name: 'fork/discard', @@ -4967,6 +4986,9 @@ describe('DeepChatTapeService', () => { expect( service.search('parent', 'legacy link needle', { scope: 'linked_subagents' }) ).toMatchObject([{ sessionId: 'legacy-child', entryId: 2 }]) + expect( + service.search('parent', 'malformed legacy link needle', { scope: 'linked_subagents' }) + ).toEqual([]) let error: unknown try { service.getContext('parent', [1], { sourceSessionId: 'discarded-child' }) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 278706842..808f29c95 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -843,6 +843,34 @@ describe('SubagentOrchestratorTool', () => { warnSpy.mockRestore() }) + it('preserves the runtime port receiver while linking a finalized Tape', async () => { + const runtimePort = { + async linkSubagentTape(input: SubagentTapeLinkInput) { + expect(this).toBe(runtimePort) + return buildTapeLinkReceipt(input) + } + } + const tool = new SubagentOrchestratorTool(runtimePort as any) + const task = { + sessionId: 'child-session', + tapeFinalized: false, + taskId: 'task-review', + slotId: 'reviewer', + title: 'Review task', + status: 'completed', + resultSummary: 'Done' + } + + await (tool as any).finalizeTaskTape({ + parentSessionId: 'parent-session', + runId: 'run-1', + task + }) + + expect(task.tapeFinalized).toBe(true) + expect(task.tapeFinalizeError).toBeUndefined() + }) + it('keeps a subagent Tape unfinalized when the runtime has no link capability', async () => { const tool = new SubagentOrchestratorTool({} as any) const task = { From 28545d9747b4c0d8ad493dc3ade95abb7cfaa3a6 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:00:38 +0800 Subject: [PATCH 10/17] docs(tape): finalize subagent tape lineage --- docs/FLOWS.md | 3 +- .../migration-and-validation.md | 3 +- .../modules/shared-data-and-io.md | 3 +- .../modules/tape-and-observability.md | 28 ++++++++++++++++ .../deepchat-tape-baseline/spec.md | 32 ++++++++++++++++--- .../retire-agent-session-presenter/spec.md | 2 +- .../session-application-coordinators/spec.md | 6 ++-- docs/architecture/session-management.md | 3 +- .../subagent-run-guardrails/spec.md | 8 ++--- .../subagent-tape-lineage/spec.md | 19 +++++------ .../subagent-tape-lineage/tasks.md | 21 +++++++----- 11 files changed, 94 insertions(+), 34 deletions(-) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index e63ddc954..7928a8119 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -115,7 +115,8 @@ sequenceDiagram ``` 当前本地 agent tools 包括文件系统、命令执行、chat settings、subagent orchestration 等能力。 -Subagent 会话以 `sessionKind='subagent'` 存储,父会话通过 tape merge/discard 处理子会话结果。 +Subagent 会话以 `sessionKind='subagent'` 存储;父会话写入冻结 child head 的 Tape link,并通过显式 +linked Tape view 回查子会话内容,不复制 child entries。 MCP/Skill/ToolPresenter 继续拥有资源和执行策略;LoopEngine 只依赖窄 port,不 import presenter。 ## 4. 会话恢复、分页和搜索 diff --git a/docs/architecture/agent-system-layered-runtime/migration-and-validation.md b/docs/architecture/agent-system-layered-runtime/migration-and-validation.md index 2290f8d68..1e7ffe4fc 100644 --- a/docs/architecture/agent-system-layered-runtime/migration-and-validation.md +++ b/docs/architecture/agent-system-layered-runtime/migration-and-validation.md @@ -117,7 +117,8 @@ Do not rename, merge or recreate these stores in this goal: - user/final/tool facts remain monotonic and idempotent by provenance; - edit/delete are replacement/retraction facts, not in-place Tape mutation; -- effective view, bootstrap/backfill, anchors, handoff/fork/merge/discard remain compatible; +- effective view, bootstrap/backfill, anchors, handoff, true-fork merge/discard, and production + subagent Tape links remain compatible; - one ViewManifest write is synchronously attempted before each actual provider request at the current point; write failure logs and remains fail-open, so a request may legally have no manifest; - trace/replay default remains metadata-only; raw payload inclusion stays opt-in; diff --git a/docs/architecture/agent-system-layered-runtime/modules/shared-data-and-io.md b/docs/architecture/agent-system-layered-runtime/modules/shared-data-and-io.md index 020b2d0b9..dc6dce195 100644 --- a/docs/architecture/agent-system-layered-runtime/modules/shared-data-and-io.md +++ b/docs/architecture/agent-system-layered-runtime/modules/shared-data-and-io.md @@ -120,7 +120,8 @@ interface AgentSharedDataPorts { - `AgentTranscriptReadPort` 服务 title/history/export/message lookup; - `AgentTranscriptMutationPort` 服务 clear/edit/delete/fork/retry preparation,retry preparation 继续执行原 summary 与 Memory invalidation,再把合法 input 交给当前 backend; -- `AgentTapePort` 服务 query/handoff/replay 与 subagent merge/discard。 +- `AgentTapePort` 服务 query/handoff/replay、显式 linked Tape view 与 production subagent link + finalization。 当前这些 port 由 `AgentRuntimePresenter` 作为过渡 adapter 实现,但 direct ACP 的网络 prompt、pending、 permission、generation 和 ACP control 不通过该 presenter 执行。后续 ownership slice 可以替换 adapter, diff --git a/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md b/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md index 590463e99..abffba603 100644 --- a/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md +++ b/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md @@ -16,6 +16,9 @@ > ASLR-080 added a pure-read causal observation slice in the existing Tape service. It joins only persisted > facts and reports the renderer event-history gap explicitly. ASLR-081 proved the reader does not mutate Tape, > projections, replay state or Memory ingestion; the event-history gap remains explicit and unresolved. +> Subagent Tape lineage now separates true fork merge from production subagent finalization. True fork deltas +> and their receipt commit atomically; production children remain independent Tapes linked by a frozen-head +> `subagent/tape_linked` event and exposed only through an explicit, authorized, read-only View scope. ## 1. 模块目的 @@ -107,6 +110,31 @@ error、记录 warning 并返回 `null`,保持请求 fail-open。 - message projection 与 Tape 无法单事务时,保持当前 commit/recovery 顺序并测试 crash window; - Memory extraction 使用 effective Tape lineage,不从临时 mutable blocks 猜测最终事实。 +### True fork 与 production subagent lineage + +- true fork 在创建时记录精确 parent head,在 merge 开始时冻结 fork head;只复制 cutoff 内的 semantic + delta,排除 `session/start` 与 `fork/start`; +- copied delta 与 parent `fork/merge` receipt 在同一 SQLite transaction 中 append;失败不留下部分 delta, + 已提交 retry 复用并校验既有 receipt; +- production subagent 是 durable child session/Tape,其 child entries 不进入 parent effective view。 + 结算统一调用 typed `linkSubagentTape()`,以 `completed | error | cancelled` outcome append + idempotent `subagent/tape_linked`; +- link receipt 冻结 child head/count。缺失 capability 或 append 失败不得把任务标记为 Tape finalized; +- legacy external `fork/merge` 可在 direct-child ownership 成立时作为 completed link 读取;legacy + `fork/discard` 只保留 audit 语义。true fork receipt 不会被误判为 external child link。 + +### Cross-Tape recall + +- `AgentTapeViewScope` 只允许 `current | linked_subagents | current_and_linked`,默认仍是 `current`; +- linked source 每次读取都以 `new_sessions` 的 persisted direct-child relationship 授权,并限制在 link + 的 frozen head;不递归 grandchildren,不接受任意 session id; +- search result 携带 source `sessionId` 并在所有 source 合并后应用一个 global limit;context 的 + `sourceSessionId` 每次只展开一个 Tape,窗口不跨 source; +- linked read 不触发 bootstrap、backfill、projection/FTS repair、Memory ingestion 或 event publish;已 + finalize 但被单独删除的 child 明确返回 unavailable; +- model surface 仍只有 `tape_search` 与 `tape_context`,没有第三个 tool,也不会把 linked child 自动注入 + provider context。 + ## 7. ViewManifest 与 trace ViewManifest 记录 context 的构成、policy/version、source entry references、summary/reconstruction 等可审计 diff --git a/docs/architecture/deepchat-tape-baseline/spec.md b/docs/architecture/deepchat-tape-baseline/spec.md index eb64b6878..e5a5cf322 100644 --- a/docs/architecture/deepchat-tape-baseline/spec.md +++ b/docs/architecture/deepchat-tape-baseline/spec.md @@ -5,8 +5,9 @@ Status: current implementation baseline. Retained Tape implementation specs are [deepchat-tape-replay-contract](../deepchat-tape-replay-contract/spec.md), [deepchat-tape-view-assembler](../deepchat-tape-view-assembler/spec.md), [deepchat-tape-view-policy](../deepchat-tape-view-policy/spec.md), -[deepchat-tape-policy-provenance](../deepchat-tape-policy-provenance/spec.md), and -[deepchat-tape-policy-selector](../deepchat-tape-policy-selector/spec.md). +[deepchat-tape-policy-provenance](../deepchat-tape-policy-provenance/spec.md), +[deepchat-tape-policy-selector](../deepchat-tape-policy-selector/spec.md), and +[subagent-tape-lineage](../subagent-tape-lineage/spec.md). This document keeps the Tape vision aligned with the current DeepChat codebase. The implementation path is: @@ -37,7 +38,8 @@ DeepChat already has the main Tape primitives. | 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. | -| Model recall tools | `agentTapeTools.ts` | Exposes the atomic `tape_search` / `tape_context` pair only when both runtime ports are available for a persisted DeepChat session. | +| Tape lineage | `DeepChatTapeService` + `new_sessions` | Keeps true forks as copy-on-merge branches and production subagents as independently readable, frozen-head Tape links. | +| Model recall tools | `agentTapeTools.ts` | Exposes the atomic `tape_search` / `tape_context` pair only when both runtime ports are available for a persisted DeepChat session; linked-subagent recall is an explicit scope on the same pair. | | Diagnostic/runtime APIs | `DeepChatTapeService` + `SessionProjectionCoordinator` | Retains info, anchor listing, and handoff below the model-tool boundary; these capabilities are not user-configurable Agent tools. | The first implementation step uses this baseline as the single runtime path. `DeepChatTapeService` @@ -60,11 +62,14 @@ docs/architecture/deepchat-tape-policy-provenance/ └── spec.md docs/architecture/deepchat-tape-policy-selector/ └── spec.md +docs/architecture/subagent-tape-lineage/ +└── spec.md ``` The retained 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. +the policy replacement boundary, `ViewManifest` policy provenance, policy selector registry, and +the distinction between true fork merge and production subagent Tape links. ## Scope Boundary @@ -86,7 +91,8 @@ the policy replacement boundary, `ViewManifest` policy provenance, and policy se ### Deferred scope for the first increment - Creating a separate TapeStore abstraction. -- Memory graph retrieval, embedding-backed topic clustering, and cross-session recall. +- Memory graph retrieval, embedding-backed topic clustering, and unrestricted or recursive + cross-session recall. Explicit recall of finalized direct-subagent Tape links is implemented. - Live LLM replay in CI. - Full eval pipeline and training exports. @@ -99,6 +105,22 @@ the policy replacement boundary, `ViewManifest` policy provenance, and policy se 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. +7. Treat persisted session ownership as the authorization source for linked Tape reads. Link events + freeze readable child heads but never copy child entries into the parent effective view. + +## Subagent Tape Lineage + +Production subagents own durable, independent Tapes. Finalization appends one idempotent +`subagent/tape_linked` event to the parent with a closed lifecycle outcome and frozen child head; +missing capability or failed persistence leaves finalization retryable. The parent can explicitly +search finalized direct children through `linked_subagents` or `current_and_linked`, and can expand +context within one authorized source Tape by `sourceSessionId`. These reads are bounded by the link +head and do not bootstrap, backfill, repair projections, or mutate Memory state. + +True forks retain different semantics: their selected delta and `fork/merge` receipt append to the +parent in one SQLite transaction, retries reuse the committed receipt, and branch-local control +anchors are excluded. Legacy external `fork/merge` records remain readable as child links; +`fork/discard` remains audit-only. No database schema migration or third model tool was added. ## Target Flow diff --git a/docs/architecture/retire-agent-session-presenter/spec.md b/docs/architecture/retire-agent-session-presenter/spec.md index 64cdf4fa3..cd79e6024 100644 --- a/docs/architecture/retire-agent-session-presenter/spec.md +++ b/docs/architecture/retire-agent-session-presenter/spec.md @@ -81,7 +81,7 @@ No object may re-export all four capability groups. | --- | --- | | create, detached create, subagent create, draft ensure, fork, delete | Lifecycle | | send, steer, pending input, message mutation, compaction, cancel, interaction | Turn | -| transfer, runtime settings, ACP config/commands, subagent Tape merge/discard | AgentAssignment | +| transfer, runtime settings, ACP config/commands, subagent Tape link | AgentAssignment | | session/message/Tape reads, window activation, rename/pin, title/status events | Projection | | permission cleanup | existing `SessionPermissionPort` | diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index 18e801afb..bc5248cb8 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -138,7 +138,7 @@ Owns executable assignment and runtime-setting policy: - transfer impact, batch move/delete, and single-session transfer; - model, project, permission, generation settings, disabled tools, and subagent-enabled settings; - ACP config options and commands; -- subagent Tape merge/discard. +- subagent Tape link finalization. Session deletion remains a lifecycle transaction. Assignment may call a required narrow lifecycle deletion port, but the composition graph must not use optional setters or circular construction. @@ -253,8 +253,8 @@ defined as `Pick` and must not be grouped under a r - Project updates retain their current non-transactional order; no rollback is introduced. - ACP model lock, workdir requirement, permission modes, generation settings, disabled tools, and config/command behavior remain unchanged. -- Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape merge/discard parent - checks remain unchanged. +- Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape link parent-child + checks remain explicit at finalization. ### Projection diff --git a/docs/architecture/session-management.md b/docs/architecture/session-management.md index 81ee3f718..330f9f88d 100644 --- a/docs/architecture/session-management.md +++ b/docs/architecture/session-management.md @@ -110,7 +110,8 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。 ## Subagent、remote 与 cron - Subagent 与普通 session 共享 app/message schema,用 `sessionKind`、`parentSessionId`、`subagentMeta` - 区分;child backend 仍由 manager 选择,父 session 通过 Tape merge/discard 接收结果。 + 区分;child backend 仍由 manager 选择。子任务结算时父 session 记录 frozen-head Tape link;显式 + cross-Tape View 只读 direct child,child entries 不复制进父 effective view。 - Remote 通过四个 consumer-owned session ports 调用 coordinator;active-generation lookup/cancel 仍使用 `AgentManagerGenerationPort`,不扫描 presenter runtime maps。 - Cron 的 composition-owned starter 通过 Lifecycle 创建 detached app session、通过 Turn send/cancel; diff --git a/docs/architecture/subagent-run-guardrails/spec.md b/docs/architecture/subagent-run-guardrails/spec.md index 1685425f4..74c55d0ba 100644 --- a/docs/architecture/subagent-run-guardrails/spec.md +++ b/docs/architecture/subagent-run-guardrails/spec.md @@ -1,6 +1,6 @@ # Subagent Run Guardrails - Spec -> Status: **implemented; final validation pending** +> Status: **implemented and validated** ## Problem @@ -29,9 +29,9 @@ inconsistent. - Foreground and background runs enforce the same deadline. - `list`, `info`, `log`, `wait`, and `kill` keep their existing meanings and run ownership checks. - Serialized run data includes the configured timeout, absolute deadline, and cancellation reason. -- Manual cancellation, recursion prevention, and completed/error tape merge/discard behavior stay - compatible. -- A cancelled child tape is not discarded until the child cancellation request settles; the run +- Manual cancellation and recursion prevention stay compatible. Completed, errored, and cancelled + children are finalized through a frozen-head Tape link. +- A cancelled child Tape is not linked until the child cancellation request settles; the run deadline itself still resolves without waiting for a blocked cancellation request. - Focused fake-timer, capacity, serialization, and handoff tests pass. diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index 4d8a002fc..124ff337d 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -1,21 +1,21 @@ # Subagent Tape Lineage - Specification -> Status: **planned** +> Status: **implemented and validated** ## Problem -DeepChat uses two different lineage models under the word "merge": +Before this change, DeepChat used the word "merge" for two different lineage models: 1. `DeepChatTapeService.mergeFork()` copies a true fork's selected delta into its parent Tape. -2. Production subagents run in independent sessions, but `mergeSubagentTape()` only appends a +2. Production subagents ran in independent sessions, but `mergeSubagentTape()` only appended a `fork/merge` event containing child metadata and a textual result summary. -The production operation is useful as an audit link, but it is not a Tape merge: child entries do -not enter the parent Tape, the parent effective view remains unchanged, and current Tape recall can -only query one session. Keeping the merge name makes the persistence contract and the model-facing -capabilities disagree. +The production operation was useful as an audit link, but it was not a Tape merge: child entries +did not enter the parent Tape, the parent effective message view remained unchanged, and Tape +recall could only query one session. Keeping the merge name made the persistence contract and the +model-facing capabilities disagree. -This specification supersedes the merge/discard wording in the implemented +This implemented specification supersedes the merge/discard wording in the implemented `subagent-run-guardrails` acceptance criteria. It preserves that specification's cancellation settlement and retry ordering while replacing the misleading persistence operation. @@ -147,7 +147,8 @@ deduplicated by source session and cutoff where appropriate. - Production subagent finalization uses link terminology and typed outcomes end to end. - A missing or failed link write remains retryable and is never reported as finalized. - True fork merge is atomic, idempotent, head-bounded, and excludes branch-local control anchors. -- Current-only recall remains byte-for-byte compatible for callers that omit new fields. +- Current-only source selection, filtering, and ordering remain compatible when callers omit new + fields; search/context outputs add explicit source identity. - Authorized linked recall returns source session IDs and never reads beyond a frozen head. - Sibling, grandchild, unrelated, deleted, and malformed legacy sources cannot leak data. - Cross-Tape search respects one global limit and context windows remain within one Tape. diff --git a/docs/architecture/subagent-tape-lineage/tasks.md b/docs/architecture/subagent-tape-lineage/tasks.md index cd52634ff..f2ab614fc 100644 --- a/docs/architecture/subagent-tape-lineage/tasks.md +++ b/docs/architecture/subagent-tape-lineage/tasks.md @@ -2,12 +2,17 @@ - [x] T1: Specify true fork merge versus production subagent link semantics. - [x] T2: Specify immutable child-head cutoff, direct-child authorization, and legacy compatibility. -- [ ] T3: Make true fork merge atomic, idempotent, and head-bounded. -- [ ] T4: Replace production merge/discard ports with typed subagent Tape links. -- [ ] T5: Add authorized linked-source resolution and cross-Tape search/context reads. -- [ ] T6: Extend the existing Tape tools and runtime routes without changing default behavior. -- [ ] T7: Add persistence, rollback, retry, lifecycle, authorization, performance-boundary, and +- [x] T3: Make true fork merge atomic, idempotent, and head-bounded. +- [x] T4: Replace production merge/discard ports with typed subagent Tape links. +- [x] T5: Add authorized linked-source resolution and cross-Tape search/context reads. +- [x] T6: Extend the existing Tape tools and runtime routes without changing default behavior. +- [x] T7: Add persistence, rollback, retry, lifecycle, authorization, performance-boundary, and non-interference tests. -- [ ] T8: Complete cumulative severity review and resolve all actionable findings. -- [ ] T9: Run focused and full validation required by this architecture slice. -- [ ] T10: Update retained Tape contracts with only validated behavior. +- [x] T8: Complete cumulative severity review and resolve all actionable findings. +- [x] T9: Run focused and full validation required by this architecture slice. +- [x] T10: Update retained Tape contracts with only validated behavior. + +Validation completed on 2026-07-15: focused lineage suites, native SQLite cases under the repository +Electron ABI, main-process tests, typecheck, i18n, lint, format, and format check passed. The full +build was intentionally not run because this slice must not refresh unrelated provider or ACP +registry artifacts. From 9d22990b8bad309e6d4d9c5ba7b83907ba40bdd0 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:13:18 +0800 Subject: [PATCH 11/17] fix(tape): recover pending subagent links --- .../subagent-tape-lineage/plan.md | 5 +- .../subagent-tape-lineage/spec.md | 6 +- .../agentTools/subagentOrchestratorTool.ts | 23 ++-- .../subagentOrchestratorTool.test.ts | 101 ++++++++++++++++++ 4 files changed, 116 insertions(+), 19 deletions(-) diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md index c8b87b6b2..f33bca1fd 100644 --- a/docs/architecture/subagent-tape-lineage/plan.md +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -26,7 +26,10 @@ DeepChat, direct ACP, session assignment, presenter, and tool runtime adapters. - Append `subagent/tape_linked` with stable task provenance and a frozen child head/count. - Update orchestration finalization so completed, error, and cancelled tasks all link after child - settlement, while absent/failed capability leaves the task retryable. + settlement, while absent/failed capability leaves the task retryable. Polling retries a terminal + task even while sibling tasks remain active, without blocking on an in-flight link. +- Reconcile terminal runtime status observed before handoff settlement as soon as the task enters + its started state. - Preserve legacy event reads without producing new legacy external fork records. ## Phase 4: Add authorized linked Tape views diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index 124ff337d..91f2b0cd0 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -46,11 +46,15 @@ concepts are deliberately separate: 5. The event references the child through `source_type = subagent`, `source_id = childSessionId`, and `source_seq = childHead`. It does not copy child payloads or raw provider data. 6. Missing link capability or failed persistence is not finalization. The orchestrator must leave - `tapeFinalized = false` so a retry remains possible and the failure is observable. + `tapeFinalized = false` so a retry remains possible and the failure is observable. Polling an + active multi-task run retries terminal tasks independently without waiting for sibling tasks or + blocking on a slow link backend. 7. Retrying the same task outcome is idempotent. It must return the existing receipt rather than append a second link. 8. Completed, errored, and cancelled tasks all retain their child Tape and use the same link operation. Outcome describes lifecycle state; it does not imply merge or deletion. +9. A child terminal update observed while its handoff is still settling is reconciled immediately + after handoff; it cannot leave the task running indefinitely or skip Tape finalization. ### True fork merge diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 8c460804b..3abb46b08 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -618,10 +618,6 @@ export class SubagentOrchestratorTool { } private async retryPendingTapeFinalization(run: MutableRunState): Promise { - if (!isTerminalStatus(run.status)) { - return - } - for (const task of run.tasks) { if (!task.sessionId || task.tapeFinalized || !isTerminalStatus(task.status)) { continue @@ -634,12 +630,8 @@ export class SubagentOrchestratorTool { } if (!run.executionSettled) { - if (task.status !== 'cancelled' || !task.cancellationSettled) { - continue - } - - // Cancellation settlement makes the frozen link safe, but a slow Tape backend must not turn the - // run deadline into another unbounded wait for foreground or polling callers. + // A terminal task can freeze independently while sibling tasks remain active. Keep polling + // non-blocking; finalizeTaskTape deduplicates in-flight work and guards cancellation settlement. void this.finalizeTaskTape(finalization) .then(() => this.updateRunStatus(run)) .catch(() => undefined) @@ -692,22 +684,18 @@ export class SubagentOrchestratorTool { if (!isTerminalStatus(run.status)) { await this.waitForRunCompletion(run, timeoutMs, options?.signal) } - if (isTerminalStatus(run.status)) { - await this.retryPendingTapeFinalization(run) - } + await this.retryPendingTapeFinalization(run) return isTerminalStatus(run.status) ? this.buildRunFinalResult(run) : this.buildRunProgressResult(run, 'Subagent run still active') } if (args.operation === 'log') { - if (isTerminalStatus(run.status)) { - await this.retryPendingTapeFinalization(run) - } + await this.retryPendingTapeFinalization(run) return this.buildRunFinalResult(run) } - if (args.operation === 'info' && isTerminalStatus(run.status)) { + if (args.operation === 'info') { await this.retryPendingTapeFinalization(run) } @@ -1220,6 +1208,7 @@ export class SubagentOrchestratorTool { if (task.status === 'queued') { task.status = 'running' } + updateTaskStatusFromRuntime(task) emitProgress() await task.completion.promise diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index 808f29c95..b8f762c13 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -923,6 +923,107 @@ describe('SubagentOrchestratorTool', () => { warnSpy.mockRestore() }) + it('reconciles early completion and retries its link while a sibling remains active', async () => { + let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null + let childIndex = 0 + const parentSession = buildSessionInfo() + const childSessions = ['completed-child', 'active-child'].map((sessionId) => + buildSessionInfo({ + sessionId, + sessionKind: 'subagent', + parentSessionId: parentSession.sessionId, + subagentEnabled: false, + availableSubagentSlots: [] + }) + ) + const earlyHandoff = createDeferredPromise() + const retryLink = createDeferredPromise() + let retryInput: SubagentTapeLinkInput | undefined + const linkSubagentTape = vi + .fn() + .mockRejectedValueOnce(new Error('transient link failure')) + .mockImplementationOnce((input: SubagentTapeLinkInput) => { + retryInput = input + return retryLink.promise + }) + .mockImplementation(async (input: SubagentTapeLinkInput) => buildTapeLinkReceipt(input)) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const runtimePort = buildRuntimePort(parentSession, { + createSubagentSession: vi.fn(async () => childSessions[childIndex++]!), + sendConversationMessage: vi.fn((sessionId: string) => + sessionId === childSessions[0].sessionId ? earlyHandoff.promise : Promise.resolve() + ), + subscribeDeepChatSessionUpdates: vi.fn((callback) => { + listener = callback + return () => { + listener = null + } + }), + linkSubagentTape + }) + const tool = new SubagentOrchestratorTool(runtimePort as any) + + const started = await tool.call( + { + mode: 'parallel', + background: true, + tasks: [ + { slotId: 'reviewer', title: 'Finish first', prompt: 'Complete immediately.' }, + { slotId: 'reviewer', title: 'Stay active', prompt: 'Keep running.' } + ] + }, + parentSession.sessionId + ) + const runId = JSON.parse((started.rawData?.toolResult as any).subagentProgress).runId + + for ( + let index = 0; + index < 20 && runtimePort.sendConversationMessage.mock.calls.length < 2; + index += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + listener?.({ + sessionId: childSessions[0].sessionId, + kind: 'status', + updatedAt: Date.now(), + status: 'idle' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(linkSubagentTape).not.toHaveBeenCalled() + earlyHandoff.resolve(undefined) + for (let index = 0; index < 20 && linkSubagentTape.mock.calls.length < 1; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + expect(linkSubagentTape).toHaveBeenCalledTimes(1) + let infoSettled = false + const infoPromise = tool + .call({ operation: 'info', runId }, parentSession.sessionId) + .then((result) => { + infoSettled = true + return result + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(linkSubagentTape).toHaveBeenCalledTimes(2) + expect(infoSettled).toBe(true) + retryLink.resolve(buildTapeLinkReceipt(retryInput!)) + await infoPromise + await new Promise((resolve) => setTimeout(resolve, 0)) + const refreshed = await tool.call({ operation: 'info', runId }, parentSession.sessionId) + const progress = JSON.parse((refreshed.rawData?.toolResult as any).subagentProgress) + expect(progress.status).toBe('running') + expect(progress.tasks).toMatchObject([ + { sessionId: 'completed-child', status: 'completed', tapeFinalized: true }, + { sessionId: 'active-child', status: 'running', tapeFinalized: false } + ]) + + await tool.call({ operation: 'kill', runId }, parentSession.sessionId) + await tool.call({ operation: 'wait', runId, timeoutMs: 1000 }, parentSession.sessionId) + warnSpy.mockRestore() + }) + it('retries failed subagent tape finalization on terminal wait', async () => { let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null const parentSession = buildSessionInfo() From 3a7cf640a0b757b45ff154626ea0c4615923f994 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:14:22 +0800 Subject: [PATCH 12/17] perf(tape): scale linked session lookup --- .../subagent-tape-lineage/plan.md | 2 ++ .../subagent-tape-lineage/spec.md | 3 ++- .../sqlitePresenter/tables/newSessions.ts | 9 ++++--- .../sqlitePresenter/newSessionsTable.test.ts | 27 ++++++++++++++++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md index f33bca1fd..a3bb584b1 100644 --- a/docs/architecture/subagent-tape-lineage/plan.md +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -37,6 +37,8 @@ - Introduce `AgentTapeViewScope` and a resolver that returns the current source and/or finalized direct-child sources with immutable cutoffs. - Reuse persisted session ownership for authorization and Tape events for finalized snapshots. +- Resolve child ownership in one indexed JSON-set query so linked-source count cannot exhaust the + SQLite bind-variable limit. - Extend projection/FTS reads to query a bounded authorized source set, enforce per-source cutoffs, and apply one global result limit. - Add source `sessionId` to search results. diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index 91f2b0cd0..9e4b0c541 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -129,7 +129,8 @@ deduplicated by source session and cutoff where appropriate. ## Performance Constraints -- Resolve direct linked sources with bounded indexed queries; do not scan every session. +- Resolve direct linked sources with bounded indexed queries and a constant number of SQL bind + parameters; do not scan every session or expand one placeholder per linked child. - Do not materialize complete child Tapes to search them. - Apply source cutoff in SQL/projection reads, and enforce one global search limit. - Avoid N+1 full-session hydration and linked-source bootstrap/backfill. diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index 6a5ff2c4e..bae517ad4 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -172,10 +172,13 @@ export class NewSessionsTable extends BaseTable { return [] } - const placeholders = ids.map(() => '?').join(', ') return this.db - .prepare(`SELECT * FROM new_sessions WHERE id IN (${placeholders})`) - .all(...ids) as NewSessionRow[] + .prepare( + `SELECT * + FROM new_sessions + WHERE id IN (SELECT value FROM json_each(?))` + ) + .all(JSON.stringify(ids)) as NewSessionRow[] } list(filters?: { diff --git a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts index f6ddb3352..f0fd1d544 100644 --- a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts +++ b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts @@ -20,7 +20,7 @@ const DatabaseCtor = Database! const NewSessionsTableCtor = NewSessionsTable! const describeIfSqlite = sqliteAvailable && NewSessionsTable ? describe : describe.skip -describeIfSqlite('NewSessionsTable clearProjectDir', () => { +describeIfSqlite('NewSessionsTable', () => { let db: InstanceType | null let table: InstanceType @@ -78,4 +78,29 @@ describeIfSqlite('NewSessionsTable clearProjectDir', () => { updated_at: 400 }) }) + + it('reads large ID sets without one SQLite bind variable per session', () => { + const insert = db!.prepare( + `INSERT INTO new_sessions ( + id, + agent_id, + title, + project_dir, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ) + insert.run('first', 'agent', 'First', null, 100, 100) + insert.run('last', 'agent', 'Last', null, 200, 200) + const ids = Array.from({ length: 40000 }, (_, index) => `missing-${index}`) + ids[0] = 'first' + ids[ids.length - 1] = 'last' + + expect( + table + .getMany(ids) + .map((row) => row.id) + .sort() + ).toEqual(['first', 'last']) + }) }) From ecf13306425fb5810a127fafc23f15757a5a2313 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:25:24 +0800 Subject: [PATCH 13/17] fix(tape): preserve linked recall integrity --- .../subagent-tape-lineage/plan.md | 4 + .../subagent-tape-lineage/spec.md | 5 + .../agentRuntimePresenter/tapeService.ts | 28 +-- .../tables/deepchatTapeSearchProjection.ts | 5 +- .../agentRuntimePresenter/tapeService.test.ts | 188 +++++++++++++++++- 5 files changed, 208 insertions(+), 22 deletions(-) diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md index a3bb584b1..526e5f537 100644 --- a/docs/architecture/subagent-tape-lineage/plan.md +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -41,11 +41,15 @@ SQLite bind-variable limit. - Extend projection/FTS reads to query a bounded authorized source set, enforce per-source cutoffs, and apply one global result limit. +- Use projection/FTS ranking only for complete exact-head coverage; on partial freshness, fall back + all selected sources together so result scores remain comparable. - Add source `sessionId` to search results. - Extend context reads with `sourceSessionId`, reject mixed or unauthorized sources, and keep one context window within one Tape. - Return explicit diagnostics for deleted or unavailable linked Tapes without triggering bootstrap, backfill, or repair writes. +- Build linked context evidence and summaries from the bounded authoritative rows already read at + the frozen head, without consulting a projection at a different freshness boundary. ## Phase 5: Expose cross-Tape recall through existing tools diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index 9e4b0c541..0fa0f70aa 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -125,6 +125,9 @@ deduplicated by source session and cutoff where appropriate. summary only; they do not duplicate raw child entries, prompts, traces, headers, or credentials. - Search projection rows are scoped by the authorized source set and cutoff before results are returned. +- Projection and FTS ranking is used only when every authorized source is fresh at its exact + cutoff. Partial freshness falls back to one authoritative read across all selected Tapes so + scored sources cannot starve unscored sources at the global limit. - Context reads reject mixed-source entry IDs and source IDs not derived from an authorized link. ## Performance Constraints @@ -133,6 +136,8 @@ deduplicated by source session and cutoff where appropriate. parameters; do not scan every session or expand one placeholder per linked child. - Do not materialize complete child Tapes to search them. - Apply source cutoff in SQL/projection reads, and enforce one global search limit. +- Derive bounded linked-context summaries from the selected authoritative Tape rows instead of + trusting projection rows whose freshness belongs to a different child head. - Avoid N+1 full-session hydration and linked-source bootstrap/backfill. - Preserve existing FTS relevance ordering with deterministic Tape order tie-breaking across sources. diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index acc0dde2e..58c284e76 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -1567,9 +1567,16 @@ export class DeepChatTapeService implements Pick searchInput ) if (projected) { - results.push(...projected.rows.map((row) => this.toProjectedSearchResult(row, undefined))) - const coveredSourceIds = new Set(projected.coveredSources.map((source) => source.sessionId)) - uncoveredSources = sources.filter((source) => !coveredSourceIds.has(source.sessionId)) + const coveredHeadBySource = new Map( + projected.coveredSources.map((source) => [source.sessionId, source.maxEntryId]) + ) + const hasCompleteProjection = + coveredHeadBySource.size === sources.length && + sources.every((source) => coveredHeadBySource.get(source.sessionId) === source.maxEntryId) + if (hasCompleteProjection) { + results.push(...projected.rows.map((row) => this.toProjectedSearchResult(row, undefined))) + uncoveredSources = [] + } } } catch (error) { logger.warn( @@ -1655,19 +1662,6 @@ export class DeepChatTapeService implements Pick after, limit }) - let projectionRows = new Map() - try { - projectionRows = new Map( - ( - this.searchProjectionTable?.getByEntryIds( - sourceSessionId, - rows.map((row) => row.entry_id) - ) ?? [] - ).map((row) => [row.entry_id, row]) - ) - } catch { - projectionRows = new Map() - } let usedBytes = 0 const entries: AgentTapeContextEntry[] = [] @@ -1675,7 +1669,7 @@ export class DeepChatTapeService implements Pick const remaining = Math.max(0, maxTotalBytes - usedBytes) if (remaining <= 0) break const maxEntryBytes = Math.min(maxBytesPerEntry, remaining) - const entry = this.toContextEntry(row, projectionRows.get(row.entry_id), maxEntryBytes) + const entry = this.toContextEntry(row, undefined, maxEntryBytes) if (entry.evidence.bytes <= 0) continue usedBytes += entry.evidence.bytes entries.push(entry) diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts index 66afdb731..f35a5e817 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts @@ -391,6 +391,9 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { if (coveredSources.length === 0 || !normalizedQuery) { return { rows: [], coveredSources } } + if (coveredSources.length !== normalizedSources.length) { + return { rows: [], coveredSources } + } const limit = normalizeLimit(options.limit) const ordered: DeepChatTapeSearchProjectionResultRow[] = [] @@ -406,7 +409,7 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { if (this.ftsReady) { const ftsSources = this.getCurrentReadSources(coveredSources, 'deepchat_tape_search_fts_meta') - if (ftsSources.length > 0) { + if (ftsSources.length === coveredSources.length) { try { collect(this.searchFtsSourcesReadOnly(ftsSources, normalizedQuery, options, limit)) } catch { diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index a49272617..8b008e9a6 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -1500,6 +1500,95 @@ describe('DeepChatTapeService', () => { ).toBe(5) }) + it('returns no ranked rows when linked projection coverage is only partial', () => { + const reads: string[] = [] + const prepare = vi.fn((sql: string) => ({ + all: () => { + reads.push(sql) + if (sql.includes('deepchat_tape_search_projection_meta AS meta')) { + return [{ session_id: 'child-a', max_entry_id: 2 }] + } + return [] + } + })) + const projectionTable = new DeepChatTapeSearchProjectionTable({ + prepare, + exec: vi.fn() + } as any) + + const result = projectionTable.searchSourcesReadOnly( + [ + { sessionId: 'child-a', maxEntryId: 2 }, + { sessionId: 'child-b', maxEntryId: 3 } + ], + 'projection needle', + { limit: 5 } + ) + + expect(result).toEqual({ + coveredSources: [{ sessionId: 'child-a', maxEntryId: 2 }], + rows: [] + }) + expect( + reads.some((sql) => sql.includes('FROM deepchat_tape_search_projection AS projection')) + ).toBe(false) + expect(reads.some((sql) => sql.includes('FROM deepchat_tape_search_fts'))).toBe(false) + }) + + it('uses one LIKE ranking when linked FTS freshness is only partial', () => { + const reads: Array<{ sql: string; params: unknown[] }> = [] + const prepare = vi.fn((sql: string) => ({ + all: (...params: unknown[]) => { + reads.push({ sql, params }) + if (sql.includes('deepchat_tape_search_projection_meta AS meta')) { + return [ + { session_id: 'child-a', max_entry_id: 2 }, + { session_id: 'child-b', max_entry_id: 3 } + ] + } + if (sql.includes('deepchat_tape_search_fts_meta AS meta')) { + return [{ session_id: 'child-a', max_entry_id: 2 }] + } + if (sql.includes('SELECT projection.*, NULL AS score')) { + return [ + { + session_id: 'child-b', + entry_id: 3, + kind: 'event', + name: 'child/result', + source_type: null, + source_id: null, + source_seq: null, + search_text: 'projection needle', + summary_text: 'projection needle', + refs_json: '{}', + created_at: 200, + score: null + } + ] + } + return [] + } + })) + const projectionTable = new DeepChatTapeSearchProjectionTable({ + prepare, + exec: vi.fn() + } as any) + ;(projectionTable as any).ftsReady = true + + const sources = [ + { sessionId: 'child-a', maxEntryId: 2 }, + { sessionId: 'child-b', maxEntryId: 3 } + ] + const result = projectionTable.searchSourcesReadOnly(sources, 'projection needle', { limit: 5 }) + + expect(result.rows).toMatchObject([{ session_id: 'child-b', entry_id: 3, score: null }]) + expect(reads.some(({ sql }) => sql.includes('bm25(deepchat_tape_search_fts)'))).toBe(false) + expect( + reads.find(({ sql }) => sql.includes('SELECT projection.*, NULL AS score'))?.params[0] + ).toBe(JSON.stringify(sources)) + }) + itIfSqlite( `queries effective linked sources and context at frozen heads${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, () => { @@ -4726,6 +4815,73 @@ describe('DeepChatTapeService', () => { expect(table.append).not.toHaveBeenCalled() }) + it('falls back all linked sources together when projection coverage is partial', () => { + const { table } = createTapeTableMock() + const projectionTable = { + searchSourcesReadOnly: vi.fn(() => ({ + coveredSources: [{ sessionId: 'child-a', maxEntryId: 2 }], + rows: [ + { + session_id: 'child-a', + entry_id: 2, + kind: 'event', + name: 'child/result', + source_type: null, + source_id: null, + source_seq: null, + search_text: 'shared partial needle', + summary_text: 'shared partial needle from A', + refs_json: '{}', + created_at: 100, + score: -100 + } + ] + })) + } + const { service } = createLinkedTapeService( + table, + [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child-a', session_kind: 'subagent', parent_session_id: 'parent' }, + { id: 'child-b', session_kind: 'subagent', parent_session_id: 'parent' } + ], + projectionTable + ) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child-a') + table.ensureBootstrapAnchor('child-b') + table.appendEvent({ + sessionId: 'child-a', + name: 'child/result', + data: { text: 'shared partial needle from A' }, + createdAt: 100 + }) + table.appendEvent({ + sessionId: 'child-b', + name: 'child/result', + data: { text: 'shared partial needle from B' }, + createdAt: 200 + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child-a')) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child-b')) + table.searchEffectiveSourcesAtHeads.mockClear() + + const hits = service.search('parent', 'shared partial needle', { + scope: 'linked_subagents', + limit: 1 + }) + + expect(hits).toMatchObject([{ sessionId: 'child-b', entryId: 2 }]) + expect(table.searchEffectiveSourcesAtHeads).toHaveBeenCalledWith( + [ + { sessionId: 'child-a', maxEntryId: 2 }, + { sessionId: 'child-b', maxEntryId: 2 } + ], + 'shared partial needle', + expect.objectContaining({ limit: 1 }) + ) + }) + it('uses an exact frozen projection read-only after the child appends a late tail', () => { const { table } = createTapeTableMock() const projectionTable = { @@ -4829,10 +4985,31 @@ describe('DeepChatTapeService', () => { it('expands linked context within one source and never crosses the frozen head', () => { const { table } = createTapeTableMock() - const { service } = createLinkedTapeService(table, [ - { id: 'parent', session_kind: 'regular', parent_session_id: null }, - { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } - ]) + const projectionTable = { + getByEntryIds: vi.fn(() => [ + { + session_id: 'child', + entry_id: 2, + kind: 'event', + name: 'child/target', + source_type: null, + source_id: null, + source_seq: null, + search_text: 'stale projection text', + summary_text: 'stale projection summary', + refs_json: '{"stale":true}', + created_at: 100 + } + ]) + } + const { service } = createLinkedTapeService( + table, + [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ], + projectionTable + ) table.ensureBootstrapAnchor('parent') table.ensureBootstrapAnchor('child') table.appendEvent({ @@ -4869,11 +5046,14 @@ describe('DeepChatTapeService', () => { matchedEntryIds: [2] }) expect(context.entries.map((entry) => entry.entryId)).toEqual([2, 3]) + expect(context.entries[0].summary).toContain('target evidence') + expect(context.entries[0].summary).not.toContain('stale projection') expect(table.getEffectiveContextRowsAtHead).toHaveBeenCalledWith( { sessionId: 'child', maxEntryId: 3 }, [2], expect.objectContaining({ before: 0, after: 5 }) ) + expect(projectionTable.getByEntryIds).not.toHaveBeenCalled() expect(table.append).not.toHaveBeenCalled() }) From c9aada742ffe653228df63ce947074b9676c4776 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:26:36 +0800 Subject: [PATCH 14/17] fix(tape): reject orphaned fork merges --- .../subagent-tape-lineage/plan.md | 6 +- .../subagent-tape-lineage/spec.md | 3 + .../agentRuntimePresenter/tapeService.ts | 42 ++++++++++ .../agentRuntimePresenter/tapeService.test.ts | 77 +++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md index 526e5f537..adeed4c54 100644 --- a/docs/architecture/subagent-tape-lineage/plan.md +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -15,8 +15,10 @@ - Add a narrow Tape-store transaction operation so copied delta and `fork/merge` receipt commit or roll back together. - Use stable merge provenance to return an existing receipt on retry. -- Cover empty forks, injected append failure, retry, and concurrent tail append boundaries with - native SQLite tests. +- Reject missing, discarded, or malformed fork starts before creating a merge receipt, while + preserving retries of an already committed receipt after cleanup. +- Cover valid empty forks, missing/discarded forks, injected append failure, retry, and concurrent + tail append boundaries with native SQLite tests. ## Phase 3: Model production subagents as links diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index 0fa0f70aa..f114b1576 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -65,6 +65,9 @@ concepts are deliberately separate: transaction. A failure leaves neither partial delta nor a receipt. 5. A retry after a committed merge is idempotent and returns the existing receipt. 6. Entries appended after the frozen fork head are outside that merge and cannot leak into it. +7. A new merge requires a valid persisted `fork/start`; a missing, discarded, or malformed fork + cannot create an empty merge receipt. An already committed receipt remains retryable after fork + cleanup. ## Cross-Tape View Contract diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index 58c284e76..dcd5f1210 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -374,6 +374,41 @@ function readForkMergeReceiptCount( return mergedCount } +function assertValidForkStart( + row: DeepChatTapeEntryRow | undefined, + parentSessionId: string, + forkId: string, + forkSessionIdValue: string +): void { + if (!row) { + throw new Error(`Fork ${forkId} does not exist or has been discarded.`) + } + const payload = parseJsonObject(row.payload_json) + const state = + payload.state && typeof payload.state === 'object' && !Array.isArray(payload.state) + ? (payload.state as Record) + : {} + const parentHeadEntryId = state.parentHeadEntryId + const hasValidLegacyOrCurrentHead = + parentHeadEntryId === undefined || + (typeof parentHeadEntryId === 'number' && + Number.isSafeInteger(parentHeadEntryId) && + parentHeadEntryId >= 0) + if ( + row.session_id !== forkSessionIdValue || + row.kind !== 'anchor' || + row.name !== 'fork/start' || + row.source_type !== 'fork' || + row.source_id !== forkId || + row.source_seq !== 0 || + row.provenance_key !== `fork:${parentSessionId}:${forkId}:start` || + state.parentSessionId !== parentSessionId || + !hasValidLegacyOrCurrentHead + ) { + throw new Error(`Stored fork start is malformed: ${row.entry_id}`) + } +} + function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkReceipt { const snapshot = parseSubagentTapeLinkSnapshot(row) if (!snapshot) { @@ -2131,6 +2166,13 @@ export class DeepChatTapeService implements Pick ) } + assertValidForkStart( + table.getByProvenanceKey(forkSessionIdValue, `fork:${parentSessionId}:${forkId}:start`), + parentSessionId, + forkId, + forkSessionIdValue + ) + const forkHeadEntryId = table.getMaxEntryId(forkSessionIdValue) const forkEntries = table .getBySessionUpToEntryId(forkSessionIdValue, forkHeadEntryId) diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 8b008e9a6..23e93f7f3 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -4524,6 +4524,59 @@ describe('DeepChatTapeService', () => { }) }) + it('rejects missing and discarded forks without committing an empty merge receipt', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + + expect(() => service.mergeFork('parent', 'missing')).toThrow( + 'Fork missing does not exist or has been discarded.' + ) + + service.createFork('parent', 'discarded') + service.discardFork('parent', 'discarded') + expect(() => service.mergeFork('parent', 'discarded')).toThrow( + 'Fork discarded does not exist or has been discarded.' + ) + expect( + entries.filter((entry) => entry.session_id === 'parent' && entry.name === 'fork/merge') + ).toEqual([]) + }) + + it('returns an existing merge receipt after the merged fork Tape is cleaned up', () => { + const { table } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + const fork = service.createFork('parent', 'cleanup-after-merge') + service.appendForkMessageRecord(fork, createRecord({ id: 'merged', sessionId: 'ignored' })) + + expect(service.mergeFork('parent', 'cleanup-after-merge')).toBe(1) + table.deleteBySession(fork.forkSessionId) + expect(service.mergeFork('parent', 'cleanup-after-merge')).toBe(1) + }) + + it('merges a legacy fork start that predates the parent head field', () => { + const { table, entries } = createTapeTableMock() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + const fork = service.createFork('parent', 'legacy-start') + const start = entries.find( + (entry) => entry.session_id === fork.forkSessionId && entry.name === 'fork/start' + )! + const payload = JSON.parse(start.payload_json) + delete payload.state.parentHeadEntryId + start.payload_json = JSON.stringify(payload) + service.appendForkMessageRecord(fork, createRecord({ id: 'legacy', sessionId: 'ignored' })) + + expect(service.mergeFork('parent', 'legacy-start')).toBe(1) + }) + it('accepts a valid legacy fork merge receipt without a frozen head', () => { const { table } = createTapeTableMock() const service = new DeepChatTapeService({ @@ -4617,6 +4670,30 @@ describe('DeepChatTapeService', () => { db.close() }) + itIfSqlite('rejects missing and discarded forks in SQLite', () => { + const db = new DatabaseCtor(':memory:') + try { + const table = new DeepChatTapeEntriesTable(db) + table.createTable() + const service = new DeepChatTapeService({ + deepchatTapeEntriesTable: table, + deepchatSessionsTable: { getSummaryState: vi.fn().mockReturnValue(null) } + } as any) + + expect(() => service.mergeFork('parent', 'missing-native')).toThrow( + 'Fork missing-native does not exist or has been discarded.' + ) + service.createFork('parent', 'discarded-native') + service.discardFork('parent', 'discarded-native') + expect(() => service.mergeFork('parent', 'discarded-native')).toThrow( + 'Fork discarded-native does not exist or has been discarded.' + ) + expect(table.getBySession('parent').some((entry) => entry.name === 'fork/merge')).toBe(false) + } finally { + db.close() + } + }) + it('cleans fork search projection on discard without blocking the discard event', () => { const { table, entries } = createTapeTableMock() const projectionTable = { From c06c4cae7e8a2b56d989ba4eff3cda99cc2093ea Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:38:01 +0800 Subject: [PATCH 15/17] fix(tape): bind links to tape incarnations --- docs/FLOWS.md | 5 +- .../modules/tape-and-observability.md | 10 +- .../deepchat-tape-baseline/spec.md | 4 +- .../subagent-tape-lineage/plan.md | 7 +- .../subagent-tape-lineage/spec.md | 26 ++- .../agentRuntimePresenter/tapeService.ts | 106 +++++++++--- .../tables/deepchatTapeEntries.ts | 33 ++++ .../agentRuntimePresenter/tapeService.test.ts | 157 +++++++++++++++++- .../deepchatTapeEntriesTable.test.ts | 26 +++ 9 files changed, 337 insertions(+), 37 deletions(-) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 7928a8119..902161c8e 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -115,8 +115,9 @@ sequenceDiagram ``` 当前本地 agent tools 包括文件系统、命令执行、chat settings、subagent orchestration 等能力。 -Subagent 会话以 `sessionKind='subagent'` 存储;父会话写入冻结 child head 的 Tape link,并通过显式 -linked Tape view 回查子会话内容,不复制 child entries。 +Subagent 会话以 `sessionKind='subagent'` 存储;父会话写入同时冻结 child Tape incarnation 与 head 的 +Tape link,并通过显式 linked Tape view 回查子会话内容,不复制 child entries。reset/rebuild 后的 child +必须重新 link,不能用复用的 entry ID 冒充旧快照。 MCP/Skill/ToolPresenter 继续拥有资源和执行策略;LoopEngine 只依赖窄 port,不 import presenter。 ## 4. 会话恢复、分页和搜索 diff --git a/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md b/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md index abffba603..2207e1b21 100644 --- a/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md +++ b/docs/architecture/agent-system-layered-runtime/modules/tape-and-observability.md @@ -119,19 +119,21 @@ error、记录 warning 并返回 `null`,保持请求 fail-open。 - production subagent 是 durable child session/Tape,其 child entries 不进入 parent effective view。 结算统一调用 typed `linkSubagentTape()`,以 `completed | error | cancelled` outcome append idempotent `subagent/tape_linked`; -- link receipt 冻结 child head/count。缺失 capability 或 append 失败不得把任务标记为 Tape finalized; +- link receipt 冻结 child head/count,link event 同时冻结 child Tape incarnation identity。缺失 + capability 或 append 失败不得把任务标记为 Tape finalized; - legacy external `fork/merge` 可在 direct-child ownership 成立时作为 completed link 读取;legacy `fork/discard` 只保留 audit 语义。true fork receipt 不会被误判为 external child link。 ### Cross-Tape recall - `AgentTapeViewScope` 只允许 `current | linked_subagents | current_and_linked`,默认仍是 `current`; -- linked source 每次读取都以 `new_sessions` 的 persisted direct-child relationship 授权,并限制在 link - 的 frozen head;不递归 grandchildren,不接受任意 session id; +- linked source 每次读取都以 `new_sessions` 的 persisted direct-child relationship 授权,并同时校验 + link 的 Tape incarnation identity 与 frozen head;不递归 grandchildren,不接受任意 session id; - search result 携带 source `sessionId` 并在所有 source 合并后应用一个 global limit;context 的 `sourceSessionId` 每次只展开一个 Tape,窗口不跨 source; - linked read 不触发 bootstrap、backfill、projection/FTS repair、Memory ingestion 或 event publish;已 - finalize 但被单独删除的 child 明确返回 unavailable; + finalize 但被单独删除或 reset/rebuild 的 child 明确返回 unavailable,直到新 incarnation 被重新 + link; - model surface 仍只有 `tape_search` 与 `tape_context`,没有第三个 tool,也不会把 linked child 自动注入 provider context。 diff --git a/docs/architecture/deepchat-tape-baseline/spec.md b/docs/architecture/deepchat-tape-baseline/spec.md index e5a5cf322..ad43cc371 100644 --- a/docs/architecture/deepchat-tape-baseline/spec.md +++ b/docs/architecture/deepchat-tape-baseline/spec.md @@ -115,7 +115,9 @@ Production subagents own durable, independent Tapes. Finalization appends one id missing capability or failed persistence leaves finalization retryable. The parent can explicitly search finalized direct children through `linked_subagents` or `current_and_linked`, and can expand context within one authorized source Tape by `sourceSessionId`. These reads are bounded by the link -head and do not bootstrap, backfill, repair projections, or mutate Memory state. +head and the linked child Tape incarnation; clearing and rebuilding a child cannot reuse entry IDs +to impersonate the snapshot. Reads do not bootstrap, backfill, repair projections, or mutate Memory +state. True forks retain different semantics: their selected delta and `fork/merge` receipt append to the parent in one SQLite transaction, retries reuse the committed receipt, and branch-local control diff --git a/docs/architecture/subagent-tape-lineage/plan.md b/docs/architecture/subagent-tape-lineage/plan.md index adeed4c54..551ccc9b5 100644 --- a/docs/architecture/subagent-tape-lineage/plan.md +++ b/docs/architecture/subagent-tape-lineage/plan.md @@ -26,7 +26,8 @@ contracts at the shared runtime boundary. - Replace `mergeSubagentTape` and `discardSubagentTape` ports with one `linkSubagentTape` port across DeepChat, direct ACP, session assignment, presenter, and tool runtime adapters. -- Append `subagent/tape_linked` with stable task provenance and a frozen child head/count. +- Append `subagent/tape_linked` with stable task provenance, a frozen child head/count, and a + cryptographic identity of the child Tape incarnation. - Update orchestration finalization so completed, error, and cancelled tasks all link after child settlement, while absent/failed capability leaves the task retryable. Polling retries a terminal task even while sibling tasks remain active, without blocking on an in-flight link. @@ -39,6 +40,10 @@ - Introduce `AgentTapeViewScope` and a resolver that returns the current source and/or finalized direct-child sources with immutable cutoffs. - Reuse persisted session ownership for authorization and Tape events for finalized snapshots. +- Give newly created Tape bootstraps an opaque incarnation marker; derive a compatible stable + identity for existing unmarked Tapes without rewriting them. +- Validate linked incarnation identities through one batched first-entry query so reset/rebuild + cannot reuse entry IDs to impersonate the frozen snapshot. - Resolve child ownership in one indexed JSON-set query so linked-source count cannot exhaust the SQLite bind-variable limit. - Extend projection/FTS reads to query a bounded authorized source set, enforce per-source cutoffs, diff --git a/docs/architecture/subagent-tape-lineage/spec.md b/docs/architecture/subagent-tape-lineage/spec.md index f114b1576..3fd024ce2 100644 --- a/docs/architecture/subagent-tape-lineage/spec.md +++ b/docs/architecture/subagent-tape-lineage/spec.md @@ -44,7 +44,8 @@ concepts are deliberately separate: 4. A successful link appends exactly one `subagent/tape_linked` event to the parent and returns a receipt containing the parent link entry, child head cutoff, child entry count, and outcome. 5. The event references the child through `source_type = subagent`, `source_id = childSessionId`, - and `source_seq = childHead`. It does not copy child payloads or raw provider data. + and `source_seq = childHead`. It also freezes a cryptographic identity of the child Tape + incarnation. It does not copy child payloads or raw provider data. 6. Missing link capability or failed persistence is not finalization. The orchestrator must leave `tapeFinalized = false` so a retry remains possible and the failure is observable. Polling an active multi-task run retries terminal tasks independently without waiting for sibling tasks or @@ -90,11 +91,12 @@ A linked source is readable only when all of the following hold: 2. The child still exists and is a direct child owned by that parent. 3. A valid finalized `subagent/tape_linked` record, regardless of task outcome, or a compatible legacy record authorizes the view. -4. Reads stop at the immutable head recorded by that link. +4. The current child Tape incarnation matches the immutable identity recorded by the link. +5. Reads stop at the immutable head recorded by that link. Arbitrary session IDs supplied by a model or caller never authorize access. Grandchildren are not -traversed. Multiple link events for one child resolve independently by finalized snapshot and are -deduplicated by source session and cutoff where appropriate. +traversed. Because the View API addresses a source by session ID rather than link entry ID, the +latest valid link event for a child defines its readable incarnation and cutoff. ### Search and context @@ -103,8 +105,9 @@ deduplicated by source session and cutoff where appropriate. - One context request expands one source Tape only. Entry windows never cross a Tape boundary. - A non-current source must resolve through the parent's authorized direct-child links and must stay within the link cutoff. -- A deleted child produces an explicit `linked_tape_unavailable` result/error. It does not silently - fall back to the parent or return partial data from another source. +- A deleted or rebuilt child produces an explicit `linked_tape_unavailable` result/error until the + current incarnation is explicitly linked. It does not silently fall back to the parent or return + partial data from another source. - Search and context remain read-only: no bootstrap, backfill, projection repair, memory ingestion, or event publication is triggered by a linked-source read. @@ -116,9 +119,11 @@ deduplicated by source session and cutoff where appropriate. 3. Old external `fork/merge` records are read as completed child links when their child session relationship is valid. Their positive `referencedEntryCount` is used as the legacy frozen cutoff because those records did not store an exact child head. -4. Old external `fork/discard` records remain audit-only and never authorize a linked view. -5. Existing true `fork/merge` records keep their original meaning. -6. No database schema migration is required. The existing Tape source fields and session +4. Pre-incarnation link records remain readable while their original unmarked legacy Tape remains + present. A Tape rebuilt with a new incarnation marker requires a new link. +5. Old external `fork/discard` records remain audit-only and never authorize a linked view. +6. Existing true `fork/merge` records keep their original meaning. +7. No database schema migration is required. The existing Tape source fields and session relationship are reused. ## Security and Privacy @@ -137,6 +142,8 @@ deduplicated by source session and cutoff where appropriate. - Resolve direct linked sources with bounded indexed queries and a constant number of SQL bind parameters; do not scan every session or expand one placeholder per linked child. +- Resolve child Tape incarnation identities in one batched first-entry query; never hash or + materialize complete child Tapes during recall. - Do not materialize complete child Tapes to search them. - Apply source cutoff in SQL/projection reads, and enforce one global search limit. - Derive bounded linked-context summaries from the selected authoritative Tape rows instead of @@ -163,6 +170,7 @@ deduplicated by source session and cutoff where appropriate. - Current-only source selection, filtering, and ordering remain compatible when callers omit new fields; search/context outputs add explicit source identity. - Authorized linked recall returns source session IDs and never reads beyond a frozen head. +- Rebuilding a child Tape cannot reuse entry IDs to impersonate a frozen linked snapshot. - Sibling, grandchild, unrelated, deleted, and malformed legacy sources cannot leak data. - Cross-Tape search respects one global limit and context windows remain within one Tape. - DeepChat and direct ACP subagent routes share the same link and recall contract. diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index dcd5f1210..a212823da 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -33,6 +33,7 @@ import type { import type { DeepChatMessageStore } from './messageStore' import { SUMMARY_ANCHOR_NAMES, + TAPE_INCARNATION_META_KEY, type DeepChatTapeReadSource, type DeepChatTapeEntryRow, type DeepChatTapeSearchInput @@ -146,6 +147,8 @@ function parseJsonValue(raw: string): unknown { } const SUBAGENT_TAPE_LINK_EVENT_NAME = 'subagent/tape_linked' +const SUBAGENT_TAPE_LINK_VERSION = 2 +const TAPE_IDENTITY_PATTERN = /^[a-f0-9]{64}$/ const SUBAGENT_TAPE_LINK_OUTCOMES = new Set([ 'completed', 'error', @@ -158,6 +161,7 @@ type SubagentTapeLinkSnapshot = { childHeadEntryId: number childEntryCount: number outcome: SubagentTapeLinkOutcome + childTapeIdentity: string | null } type LinkedTapeSourceResolution = { @@ -211,7 +215,39 @@ export function normalizeSubagentTapeLinkInput( return normalized } +function isUnmarkedLegacyTape(row: DeepChatTapeEntryRow): boolean { + const meta = parseJsonValue(row.meta_json) + return ( + meta !== null && + typeof meta === 'object' && + !Array.isArray(meta) && + !Object.prototype.hasOwnProperty.call(meta, TAPE_INCARNATION_META_KEY) + ) +} + +function computeTapeIdentity(row: DeepChatTapeEntryRow): string { + return createHash('sha256') + .update( + JSON.stringify([ + row.session_id, + row.entry_id, + row.kind, + row.name, + row.source_type, + row.source_id, + row.source_seq, + row.provenance_key, + row.payload_json, + row.meta_json, + row.created_at + ]) + ) + .digest('hex') +} + function subagentTapeLinkProvenanceKey(input: SubagentTapeLinkInput): string { + // This version belongs to the stable task-identity key, independently of the evolving event + // payload's linkVersion. const identityHash = createHash('sha256') .update( JSON.stringify([input.parentSessionId, input.childSessionId, input.runId, input.taskId]) @@ -235,6 +271,13 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL const slotId = data.slotId const taskTitle = data.taskTitle const resultSummary = data.resultSummary + const linkVersion = data.linkVersion + const childTapeIdentity = data.childTapeIdentity + const hasValidLinkVersion = + (linkVersion === 1 && childTapeIdentity === undefined) || + (linkVersion === SUBAGENT_TAPE_LINK_VERSION && + typeof childTapeIdentity === 'string' && + TAPE_IDENTITY_PATTERN.test(childTapeIdentity)) if ( row.kind !== 'event' || row.name !== SUBAGENT_TAPE_LINK_EVENT_NAME || @@ -253,7 +296,7 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL typeof slotId !== 'string' || typeof taskTitle !== 'string' || (resultSummary !== null && typeof resultSummary !== 'string') || - data.linkVersion !== 1 || + !hasValidLinkVersion || row.source_type !== 'subagent' || row.source_id !== childSessionId || row.source_seq !== childHeadEntryId || @@ -295,7 +338,9 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL childSessionId, childHeadEntryId, childEntryCount, - outcome: outcome as SubagentTapeLinkOutcome + outcome: outcome as SubagentTapeLinkOutcome, + childTapeIdentity: + linkVersion === SUBAGENT_TAPE_LINK_VERSION ? (childTapeIdentity as string) : null } } @@ -331,7 +376,8 @@ function parseLegacyExternalTapeLinkSnapshot( childSessionId, childHeadEntryId: referencedEntryCount, childEntryCount: referencedEntryCount, - outcome: 'completed' + outcome: 'completed', + childTapeIdentity: null } } @@ -1539,14 +1585,22 @@ export class DeepChatTapeService implements Pick ) } - const snapshots = table + const parsedSnapshots = table .getSubagentLineageEvents(parentSessionId) .map((row) => parseSubagentTapeLinkSnapshot(row) ?? parseLegacyExternalTapeLinkSnapshot(row)) .filter((snapshot): snapshot is SubagentTapeLinkSnapshot => snapshot !== null) + const latestSnapshotByChild = new Map() + for (const snapshot of parsedSnapshots) { + const current = latestSnapshotByChild.get(snapshot.childSessionId) + if (!current || snapshot.linkEntryId > current.linkEntryId) { + latestSnapshotByChild.set(snapshot.childSessionId, snapshot) + } + } + const snapshots = [...latestSnapshotByChild.values()] const childSessionIds = [...new Set(snapshots.map((snapshot) => snapshot.childSessionId))] const childById = new Map(sessionTable.getMany(childSessionIds).map((row) => [row.id, row])) const unavailableSourceIds = new Set() - const maxEntryIdBySource = new Map() + const snapshotBySource = new Map() for (const snapshot of snapshots) { const child = childById.get(snapshot.childSessionId) @@ -1557,26 +1611,34 @@ export class DeepChatTapeService implements Pick if (child.session_kind !== 'subagent' || child.parent_session_id !== parentSessionId) { continue } - maxEntryIdBySource.set( - snapshot.childSessionId, - Math.max(maxEntryIdBySource.get(snapshot.childSessionId) ?? 0, snapshot.childHeadEntryId) - ) + snapshotBySource.set(snapshot.childSessionId, snapshot) } - const liveHeads = table.getMaxEntryIdsBySessions([...maxEntryIdBySource.keys()]) - for (const [sourceSessionId, maxEntryId] of maxEntryIdBySource) { - if ((liveHeads.get(sourceSessionId) ?? 0) < maxEntryId) { - maxEntryIdBySource.delete(sourceSessionId) + const authorizedSourceIds = [...snapshotBySource.keys()] + const firstEntryBySource = new Map( + table.getFirstEntriesBySessions(authorizedSourceIds).map((row) => [row.session_id, row]) + ) + const liveHeads = table.getMaxEntryIdsBySessions(authorizedSourceIds) + const availableSources: DeepChatTapeReadSource[] = [] + for (const [sourceSessionId, snapshot] of snapshotBySource) { + const firstEntry = firstEntryBySource.get(sourceSessionId) + const identityMatches = snapshot.childTapeIdentity + ? firstEntry !== undefined && computeTapeIdentity(firstEntry) === snapshot.childTapeIdentity + : firstEntry !== undefined && isUnmarkedLegacyTape(firstEntry) + if (!identityMatches || (liveHeads.get(sourceSessionId) ?? 0) < snapshot.childHeadEntryId) { unavailableSourceIds.add(sourceSessionId) + continue } + availableSources.push({ + sessionId: sourceSessionId, + maxEntryId: snapshot.childHeadEntryId + }) } return { - sources: [...maxEntryIdBySource.entries()] - .map(([sessionId, maxEntryId]) => ({ sessionId, maxEntryId })) - .sort((left, right) => - left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0 - ), + sources: availableSources.sort((left, right) => + left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0 + ), unavailableSourceIds } } @@ -2275,6 +2337,11 @@ export class DeepChatTapeService implements Pick return receipt } + const childFirstEntry = table.getFirstEntriesBySessions([normalized.childSessionId])[0] + if (!childFirstEntry || childFirstEntry.session_id !== normalized.childSessionId) { + throw new Error(`Subagent Tape ${normalized.childSessionId} is unavailable.`) + } + const childTapeIdentity = computeTapeIdentity(childFirstEntry) const childHeadEntryId = table.getMaxEntryId(normalized.childSessionId) const childEntryCount = table.countBySession(normalized.childSessionId) const row = table.appendEvent({ @@ -2287,10 +2354,11 @@ export class DeepChatTapeService implements Pick }, provenanceKey, data: { - linkVersion: 1, + linkVersion: SUBAGENT_TAPE_LINK_VERSION, childSessionId: normalized.childSessionId, childHeadEntryId, childEntryCount, + childTapeIdentity, runId: normalized.runId, taskId: normalized.taskId, slotId: normalized.slotId, diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts index c4905e615..db1b464d1 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts @@ -1,9 +1,12 @@ import Database from 'better-sqlite3-multiple-ciphers' import logger from '@shared/logger' +import { randomUUID } from 'crypto' import { BaseTable } from './baseTable' export type DeepChatTapeEntryKind = 'event' | 'anchor' | 'message' | 'tool_call' | 'tool_result' +export const TAPE_INCARNATION_META_KEY = 'tapeIncarnationId' + export type DeepChatTapeSourceType = | 'session' | 'message' @@ -614,6 +617,9 @@ export class DeepChatTapeEntriesTable extends BaseTable { state: { owner: 'human' }, + meta: { + [TAPE_INCARNATION_META_KEY]: randomUUID() + }, idempotent: true }) } @@ -642,6 +648,33 @@ export class DeepChatTapeEntriesTable extends BaseTable { .all(sessionId) as DeepChatTapeEntryRow[] } + getFirstEntriesBySessions(sessionIds: string[]): DeepChatTapeEntryRow[] { + const ids = [...new Set(sessionIds.map((id) => id.trim()).filter(Boolean))] + if (ids.length === 0) { + return [] + } + return this.db + .prepare( + `WITH requested_sessions(session_id) AS ( + SELECT value FROM json_each(?) + ), + first_entries(session_id, entry_id) AS ( + SELECT tape.session_id, MIN(tape.entry_id) + FROM deepchat_tape_entries AS tape + INNER JOIN requested_sessions AS requested + ON requested.session_id = tape.session_id + GROUP BY tape.session_id + ) + SELECT tape.* + FROM deepchat_tape_entries AS tape + INNER JOIN first_entries AS first + ON first.session_id = tape.session_id + AND first.entry_id = tape.entry_id + ORDER BY tape.session_id ASC` + ) + .all(JSON.stringify(ids)) as DeepChatTapeEntryRow[] + } + getBySessionUpToEntryId(sessionId: string, maxEntryId: number): DeepChatTapeEntryRow[] { return this.db .prepare( diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 23e93f7f3..333e2da05 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -62,6 +62,7 @@ const itIfSqlite = sqliteAvailable function createTapeTableMock() { const entries: any[] = [] + let tapeIncarnationSequence = 0 const table = { ensureBootstrapAnchor: vi.fn((sessionId: string) => { if ( @@ -74,6 +75,7 @@ function createTapeTableMock() { name: 'session/start', source: { type: 'session', id: sessionId, seq: 0 }, state: { owner: 'human' }, + meta: { tapeIncarnationId: `test-tape-${++tapeIncarnationSequence}` }, idempotent: true }) }), @@ -155,6 +157,16 @@ function createTapeTableMock() { (entry.name === 'subagent/tape_linked' || entry.name === 'fork/merge') ) ), + getFirstEntriesBySessions: vi.fn((sessionIds: string[]) => + [...new Set(sessionIds)] + .flatMap((sessionId) => { + const first = entries + .filter((entry) => entry.session_id === sessionId) + .sort((left, right) => left.entry_id - right.entry_id)[0] + return first ? [first] : [] + }) + .sort((left, right) => left.session_id.localeCompare(right.session_id)) + ), getBySessionUpToEntryId: vi.fn((sessionId: string, maxEntryId: number) => entries.filter((entry) => entry.session_id === sessionId && entry.entry_id <= maxEntryId) ), @@ -4760,6 +4772,10 @@ describe('DeepChatTapeService', () => { source_id: 'child', source_seq: 2 }) + expect(JSON.parse(links[0].payload_json).data).toMatchObject({ + linkVersion: 2, + childTapeIdentity: expect.stringMatching(/^[a-f0-9]{64}$/) + }) expect( entries.some((entry) => entry.session_id === 'parent' && entry.name === 'child/result') ).toBe(false) @@ -4818,6 +4834,68 @@ describe('DeepChatTapeService', () => { ) }) + it('keeps a version-one link readable while its original unmarked Tape remains present', () => { + const { table, entries } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/result', + data: { text: 'version one compatibility marker' } + }) + const input = createSubagentLinkInput('parent', 'child') + const receipt = service.linkSubagentTape(input) + const link = entries.find( + (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' + )! + const payload = JSON.parse(link.payload_json) + payload.data.linkVersion = 1 + delete payload.data.childTapeIdentity + link.payload_json = JSON.stringify(payload) + entries.find((entry) => entry.session_id === 'child' && entry.entry_id === 1)!.meta_json = '{}' + + expect(service.linkSubagentTape(input)).toEqual(receipt) + expect( + service.search('parent', 'version one compatibility marker', { + scope: 'linked_subagents' + }) + ).toMatchObject([{ sessionId: 'child', entryId: 2 }]) + }) + + it('fails closed when a version-one link points at malformed incarnation metadata', () => { + const { table, entries } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/result', + data: { text: 'malformed incarnation metadata marker' } + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + const link = entries.find( + (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' + )! + const payload = JSON.parse(link.payload_json) + payload.data.linkVersion = 1 + delete payload.data.childTapeIdentity + link.payload_json = JSON.stringify(payload) + entries.find((entry) => entry.session_id === 'child' && entry.entry_id === 1)!.meta_json = '{' + + expect(() => + service.search('parent', 'malformed incarnation metadata marker', { + scope: 'linked_subagents' + }) + ).toThrowError(/Linked Tape child is unavailable/) + }) + it('searches direct linked children at frozen heads with one global limit', () => { const { table } = createTapeTableMock() const { service } = createLinkedTapeService(table, [ @@ -5182,8 +5260,81 @@ describe('DeepChatTapeService', () => { }) }) - it('reads legacy external merge links and keeps legacy discard audit-only', () => { + it('rejects a rebuilt child Tape until a new incarnation is explicitly linked', () => { const { table } = createTapeTableMock() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/original', + data: { text: 'original incarnation marker' } + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + + table.deleteBySession('child') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/rebuilt', + data: { text: 'rebuilt incarnation marker' } + }) + expect( + service.search('child', 'rebuilt incarnation marker', { scope: 'current' }) + ).toHaveLength(1) + expect(() => + service.search('parent', 'rebuilt incarnation marker', { scope: 'linked_subagents' }) + ).toThrowError(/Linked Tape child is unavailable/) + + service.linkSubagentTape({ + ...createSubagentLinkInput('parent', 'child'), + runId: 'run-rebuilt-child', + taskId: 'task-rebuilt-child' + }) + expect( + service.search('parent', 'rebuilt incarnation marker', { scope: 'linked_subagents' }) + ).toMatchObject([{ sessionId: 'child', entryId: 2 }]) + }) + + itIfSqlite('detects linked Tape replacement after entry ids are reused in SQLite', () => { + const db = new DatabaseCtor(':memory:') + try { + const table = new DeepChatTapeEntriesTable(db) + table.createTable() + const { service } = createLinkedTapeService(table, [ + { id: 'parent', session_kind: 'regular', parent_session_id: null }, + { id: 'child', session_kind: 'subagent', parent_session_id: 'parent' } + ]) + table.ensureBootstrapAnchor('parent') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/original', + data: { text: 'native original incarnation' } + }) + service.linkSubagentTape(createSubagentLinkInput('parent', 'child')) + + table.deleteBySession('child') + table.ensureBootstrapAnchor('child') + table.appendEvent({ + sessionId: 'child', + name: 'child/rebuilt', + data: { text: 'native rebuilt incarnation' } + }) + + expect(() => + service.search('parent', 'native rebuilt incarnation', { scope: 'linked_subagents' }) + ).toThrowError(/Linked Tape child is unavailable/) + } finally { + db.close() + } + }) + + it('reads legacy external merge links and keeps legacy discard audit-only', () => { + const { table, entries } = createTapeTableMock() const { service } = createLinkedTapeService(table, [ { id: 'parent', session_kind: 'regular', parent_session_id: null }, { id: 'legacy-child', session_kind: 'subagent', parent_session_id: 'parent' }, @@ -5194,6 +5345,10 @@ describe('DeepChatTapeService', () => { table.ensureBootstrapAnchor('legacy-child') table.ensureBootstrapAnchor('malformed-child') table.ensureBootstrapAnchor('discarded-child') + const legacyBootstrap = entries.find( + (entry) => entry.session_id === 'legacy-child' && entry.entry_id === 1 + )! + legacyBootstrap.meta_json = '{}' table.appendEvent({ sessionId: 'legacy-child', name: 'child/result', diff --git a/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts b/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts index 068443390..5d35b8af3 100644 --- a/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts +++ b/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts @@ -87,6 +87,32 @@ describeIfSqlite('DeepChatTapeEntriesTable', () => { db.close() }) + it('assigns a new Tape incarnation when a session Tape is rebuilt', () => { + const { db, table } = createTable() + table.ensureBootstrapAnchor('s1') + table.ensureBootstrapAnchor('s2') + + const firstRows = table.getFirstEntriesBySessions(['s2', 'missing', 's1']) + const firstIncarnation = JSON.parse( + firstRows.find((row) => row.session_id === 's1')!.meta_json + ).tapeIncarnationId + const secondIncarnation = JSON.parse( + firstRows.find((row) => row.session_id === 's2')!.meta_json + ).tapeIncarnationId + expect(firstIncarnation).toEqual(expect.any(String)) + expect(secondIncarnation).toEqual(expect.any(String)) + expect(secondIncarnation).not.toBe(firstIncarnation) + + table.deleteBySession('s1') + table.ensureBootstrapAnchor('s1') + const rebuiltIncarnation = JSON.parse( + table.getFirstEntriesBySessions(['s1'])[0].meta_json + ).tapeIncarnationId + expect(rebuiltIncarnation).not.toBe(firstIncarnation) + + db.close() + }) + it('tracks the latest summary-related anchor only within the requested session', () => { const { db, table } = createTable() From ac88903495edfc0f65ce1409fff0ca171e576b69 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 00:44:11 +0800 Subject: [PATCH 16/17] docs(tape): record lineage validation --- docs/architecture/subagent-tape-lineage/tasks.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/architecture/subagent-tape-lineage/tasks.md b/docs/architecture/subagent-tape-lineage/tasks.md index f2ab614fc..385d0844a 100644 --- a/docs/architecture/subagent-tape-lineage/tasks.md +++ b/docs/architecture/subagent-tape-lineage/tasks.md @@ -12,7 +12,8 @@ - [x] T9: Run focused and full validation required by this architecture slice. - [x] T10: Update retained Tape contracts with only validated behavior. -Validation completed on 2026-07-15: focused lineage suites, native SQLite cases under the repository -Electron ABI, main-process tests, typecheck, i18n, lint, format, and format check passed. The full -build was intentionally not run because this slice must not refresh unrelated provider or ACP -registry artifacts. +Validation repeated on 2026-07-16 after lifecycle retry, source-scaling, projection-freshness, +fork-boundary, and Tape-incarnation hardening: focused lineage suites, native SQLite cases under +the repository Electron ABI, main-process tests, typecheck, i18n, lint, format, and format check +passed. The full build was intentionally not run because this slice must not refresh unrelated +provider or ACP registry artifacts. From a1f2b90ced0f7f228a5b355cf4bbf10162f0ed61 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Thu, 16 Jul 2026 09:04:16 +0800 Subject: [PATCH 17/17] fix(tape): freeze links and align fallback search --- .../agentRuntimePresenter/tapeService.ts | 47 +++++++---- .../tables/deepchatTapeEntries.ts | 69 ++++++++++++---- .../tables/deepchatTapeSearchProjection.ts | 78 ++++--------------- .../contracts/routes/sessions.routes.ts | 2 +- .../agentRuntimePresenter/tapeService.test.ts | 74 ++++++++++++++++++ .../deepchatTapeEntriesTable.test.ts | 1 + 6 files changed, 180 insertions(+), 91 deletions(-) diff --git a/src/main/presenter/agentRuntimePresenter/tapeService.ts b/src/main/presenter/agentRuntimePresenter/tapeService.ts index a212823da..c32d09f23 100644 --- a/src/main/presenter/agentRuntimePresenter/tapeService.ts +++ b/src/main/presenter/agentRuntimePresenter/tapeService.ts @@ -164,6 +164,11 @@ type SubagentTapeLinkSnapshot = { childTapeIdentity: string | null } +type ParsedSubagentTapeLink = { + snapshot: SubagentTapeLinkSnapshot + frozenInput: SubagentTapeLinkInput +} + type LinkedTapeSourceResolution = { sources: DeepChatTapeReadSource[] unavailableSourceIds: Set @@ -256,7 +261,7 @@ function subagentTapeLinkProvenanceKey(input: SubagentTapeLinkInput): string { return `subagent:tape-link:v1:${identityHash}` } -function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeLinkSnapshot | null { +function parseSubagentTapeLink(row: DeepChatTapeEntryRow): ParsedSubagentTapeLink | null { const payload = parseJsonObject(row.payload_json) const data = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) @@ -334,16 +339,23 @@ function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeL } return { - linkEntryId: row.entry_id, - childSessionId, - childHeadEntryId, - childEntryCount, - outcome: outcome as SubagentTapeLinkOutcome, - childTapeIdentity: - linkVersion === SUBAGENT_TAPE_LINK_VERSION ? (childTapeIdentity as string) : null + snapshot: { + linkEntryId: row.entry_id, + childSessionId, + childHeadEntryId, + childEntryCount, + outcome: outcome as SubagentTapeLinkOutcome, + childTapeIdentity: + linkVersion === SUBAGENT_TAPE_LINK_VERSION ? (childTapeIdentity as string) : null + }, + frozenInput: normalizedInput } } +function parseSubagentTapeLinkSnapshot(row: DeepChatTapeEntryRow): SubagentTapeLinkSnapshot | null { + return parseSubagentTapeLink(row)?.snapshot ?? null +} + function parseLegacyExternalTapeLinkSnapshot( row: DeepChatTapeEntryRow ): SubagentTapeLinkSnapshot | null { @@ -472,14 +484,19 @@ function toSubagentTapeLinkReceipt(row: DeepChatTapeEntryRow): SubagentTapeLinkR } } -function assertSubagentTapeLinkReceiptMatchesInput( - receipt: SubagentTapeLinkReceipt, +function assertSubagentTapeLinkMatchesInput( + row: DeepChatTapeEntryRow, input: SubagentTapeLinkInput ): void { + const parsed = parseSubagentTapeLink(row) + if (!parsed) { + throw new Error(`Stored subagent Tape link receipt is malformed: ${row.entry_id}`) + } + const storedInput = parsed.frozenInput + const storedKeys = Object.keys(storedInput) as Array if ( - receipt.linkEntry.sessionId !== input.parentSessionId || - receipt.childSessionId !== input.childSessionId || - receipt.outcome !== input.outcome + storedKeys.length !== Object.keys(input).length || + storedKeys.some((key) => storedInput[key] !== input[key]) ) { throw new Error( `Subagent Tape link conflicts with finalized task ${input.runId}/${input.taskId}.` @@ -2332,8 +2349,8 @@ export class DeepChatTapeService implements Pick return table.runInTransaction(() => { const existing = table.getByProvenanceKey(normalized.parentSessionId, provenanceKey) if (existing) { + assertSubagentTapeLinkMatchesInput(existing, normalized) const receipt = toSubagentTapeLinkReceipt(existing) - assertSubagentTapeLinkReceiptMatchesInput(receipt, normalized) return receipt } @@ -2368,8 +2385,8 @@ export class DeepChatTapeService implements Pick }, idempotent: true }) + assertSubagentTapeLinkMatchesInput(row, normalized) const receipt = toSubagentTapeLinkReceipt(row) - assertSubagentTapeLinkReceiptMatchesInput(receipt, normalized) return receipt }) } diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts index db1b464d1..61a0fb993 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries.ts @@ -117,6 +117,50 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (character) => `\\${character}`) } +// Three LIKE parameters per field group stay below SQLite's portable 999-variable floor after +// source, filter, and limit bindings. +const MAX_TAPE_SEARCH_TOKEN_CLAUSES = 256 + +function tokenizeDeepChatTapeSearchQuery(value: string): string[] { + return value + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean) +} + +export function buildDeepChatTapeFtsMatch(value: string): string { + const tokens = tokenizeDeepChatTapeSearchQuery(value) + const values = + tokens.length > 1 && tokens.length <= MAX_TAPE_SEARCH_TOKEN_CLAUSES ? tokens : [value] + return values.map((token) => `"${token.replace(/"/g, '""')}"`).join(' AND ') +} + +export function buildDeepChatTapeLikeSearchPredicate( + fieldExpressions: readonly [string, ...string[]], + normalizedQuery: string +): { sql: string; params: string[] } { + const fieldClause = `(${fieldExpressions + .map((field) => `${field} LIKE ? ESCAPE '\\'`) + .join(' OR ')})` + const queryClauses = [fieldClause] + const queryPattern = `%${escapeLikePattern(normalizedQuery)}%` + const params = fieldExpressions.map(() => queryPattern) + const tokens = tokenizeDeepChatTapeSearchQuery(normalizedQuery) + + if (tokens.length > 1 && tokens.length <= MAX_TAPE_SEARCH_TOKEN_CLAUSES) { + queryClauses.push(`(${tokens.map(() => fieldClause).join(' AND ')})`) + for (const token of tokens) { + const tokenPattern = `%${escapeLikePattern(token)}%` + params.push(...fieldExpressions.map(() => tokenPattern)) + } + } + + return { + sql: `(${queryClauses.join(' OR ')})`, + params + } +} + export function normalizeDeepChatTapeReadSources( sources: readonly DeepChatTapeReadSource[] ): DeepChatTapeReadSource[] { @@ -914,12 +958,12 @@ export class DeepChatTapeEntriesTable extends BaseTable { } const limit = Number.isFinite(options.limit) ? (options.limit as number) : 20 const cappedLimit = Math.min(Math.max(Math.floor(limit), 1), 100) - const whereClauses = [ - 'session_id = ?', - "(payload_json LIKE ? ESCAPE '\\' OR meta_json LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\')" - ] - const queryPattern = `%${escapeLikePattern(normalizedQuery)}%` - const params: Array = [sessionId, queryPattern, queryPattern, queryPattern] + const queryPredicate = buildDeepChatTapeLikeSearchPredicate( + ['payload_json', 'meta_json', 'name'], + normalizedQuery + ) + const whereClauses = ['session_id = ?', queryPredicate.sql] + const params: Array = [sessionId, ...queryPredicate.params] if (options.kinds?.length) { whereClauses.push(`kind IN (${options.kinds.map(() => '?').join(', ')})`) @@ -962,15 +1006,14 @@ export class DeepChatTapeEntriesTable extends BaseTable { const limit = Number.isFinite(options.limit) ? (options.limit as number) : 20 const cappedLimit = Math.min(Math.max(Math.floor(limit), 1), 100) - const whereClauses = [ - "(candidate.payload_json LIKE ? ESCAPE '\\' OR candidate.meta_json LIKE ? ESCAPE '\\' OR candidate.name LIKE ? ESCAPE '\\')" - ] - const queryPattern = `%${escapeLikePattern(normalizedQuery)}%` + const queryPredicate = buildDeepChatTapeLikeSearchPredicate( + ['candidate.payload_json', 'candidate.meta_json', 'candidate.name'], + normalizedQuery + ) + const whereClauses = [queryPredicate.sql] const params: Array = [ serializeDeepChatTapeReadSources(normalizedSources), - queryPattern, - queryPattern, - queryPattern + ...queryPredicate.params ] if (options.kinds?.length) { diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts index f35a5e817..7f03d3819 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatTapeSearchProjection.ts @@ -1,6 +1,8 @@ import Database from 'better-sqlite3-multiple-ciphers' import { BaseTable } from './baseTable' import { + buildDeepChatTapeFtsMatch, + buildDeepChatTapeLikeSearchPredicate, normalizeDeepChatTapeReadSources, serializeDeepChatTapeReadSources } from './deepchatTapeEntries' @@ -64,10 +66,6 @@ const TAPE_SEARCH_PROJECTION_INDEX_SQL = ` ON deepchat_tape_search_projection(session_id, created_at, entry_id); ` -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (character) => `\\${character}`) -} - function normalizeLimit(limit: number | undefined): number { return Math.min(Math.max(Math.floor(Number.isFinite(limit) ? (limit as number) : 20), 1), 100) } @@ -87,19 +85,6 @@ function parseRefs(value: string): Record { } } -function tokenizeQuery(value: string): string[] { - return value - .split(/\s+/) - .map((token) => token.trim()) - .filter(Boolean) -} - -function buildFtsMatch(value: string): string { - const tokens = tokenizeQuery(value) - const values = tokens.length > 1 ? tokens : [value] - return values.map((token) => `"${token.replace(/"/g, '""')}"`).join(' AND ') -} - export class DeepChatTapeSearchProjectionTable extends BaseTable { private ftsCapability: FtsCapability | undefined private ftsReady = false @@ -734,7 +719,7 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { options: DeepChatTapeSearchInput, limit: number ): DeepChatTapeSearchProjectionResultRow[] { - const match = buildFtsMatch(normalized) + const match = buildDeepChatTapeFtsMatch(normalized) const whereClauses = [ 'deepchat_tape_search_fts MATCH ?', 'deepchat_tape_search_fts.session_id = ?', @@ -811,7 +796,7 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { options: DeepChatTapeSearchInput, limit: number ): DeepChatTapeSearchProjectionResultRow[] { - const match = buildFtsMatch(normalized) + const match = buildDeepChatTapeFtsMatch(normalized) const whereClauses = ['deepchat_tape_search_fts MATCH ?'] const params: Array = [serializeDeepChatTapeReadSources(sources), match] this.addFilters(whereClauses, params, options, true, 'projection') @@ -859,29 +844,13 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { options: DeepChatTapeSearchInput, limit: number ): DeepChatTapeSearchProjectionResultRow[] { - const whereClauses: string[] = [] - const pattern = `%${escapeLikePattern(normalized)}%` + const queryPredicate = buildDeepChatTapeLikeSearchPredicate( + ['projection.search_text', 'projection.summary_text', 'projection.name'], + normalized + ) + const whereClauses = [queryPredicate.sql] const params: Array = [serializeDeepChatTapeReadSources(sources)] - const queryClauses = [ - "(projection.search_text LIKE ? ESCAPE '\\' OR projection.summary_text LIKE ? ESCAPE '\\' OR projection.name LIKE ? ESCAPE '\\')" - ] - params.push(pattern, pattern, pattern) - const tokens = tokenizeQuery(normalized) - if (tokens.length > 1) { - queryClauses.push( - `(${tokens - .map( - () => - "(projection.search_text LIKE ? ESCAPE '\\' OR projection.summary_text LIKE ? ESCAPE '\\' OR projection.name LIKE ? ESCAPE '\\')" - ) - .join(' AND ')})` - ) - for (const token of tokens) { - const tokenPattern = `%${escapeLikePattern(token)}%` - params.push(tokenPattern, tokenPattern, tokenPattern) - } - } - whereClauses.push(`(${queryClauses.join(' OR ')})`) + params.push(...queryPredicate.params) this.addFilters(whereClauses, params, options, false, 'projection') params.push(limit) return this.db @@ -910,28 +879,13 @@ export class DeepChatTapeSearchProjectionTable extends BaseTable { options: DeepChatTapeSearchInput, limit: number ): DeepChatTapeSearchProjectionResultRow[] { - const whereClauses = ['session_id = ?'] - const pattern = `%${escapeLikePattern(normalized)}%` + const queryPredicate = buildDeepChatTapeLikeSearchPredicate( + ['search_text', 'summary_text', 'name'], + normalized + ) + const whereClauses = ['session_id = ?', queryPredicate.sql] const params: Array = [sessionId] - const queryClauses = [ - "(search_text LIKE ? ESCAPE '\\' OR summary_text LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\')" - ] - params.push(pattern, pattern, pattern) - const termClauses: string[] = [] - const tokens = tokenizeQuery(normalized) - for (let index = 0; index < tokens.length; index += 1) { - termClauses.push( - "(search_text LIKE ? ESCAPE '\\' OR summary_text LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\')" - ) - } - if (tokens.length > 1) { - queryClauses.push(`(${termClauses.join(' AND ')})`) - for (const token of tokens) { - const tokenPattern = `%${escapeLikePattern(token)}%` - params.push(tokenPattern, tokenPattern, tokenPattern) - } - } - whereClauses.push(`(${queryClauses.join(' OR ')})`) + params.push(...queryPredicate.params) this.addFilters(whereClauses, params, options) params.push(limit) return this.db diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index b655a7ba4..54916483d 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -368,7 +368,7 @@ export const sessionsGetTapeContextRoute = defineRouteContract({ limit: z.number().int().positive().max(100).optional(), maxBytesPerEntry: z.number().int().min(0).max(8192).optional(), maxTotalBytes: z.number().int().min(0).max(65536).optional(), - sourceSessionId: z.string().trim().min(1).optional() + sourceSessionId: EntityIdSchema.trim().min(1).optional() }) .optional() }), diff --git a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts index 333e2da05..50e23e61c 100644 --- a/test/main/presenter/agentRuntimePresenter/tapeService.test.ts +++ b/test/main/presenter/agentRuntimePresenter/tapeService.test.ts @@ -1134,6 +1134,13 @@ describe('DeepChatTapeService', () => { 5 ] ) + + const oversizedQuery = Array.from({ length: 257 }, (_, index) => `term-${index}`).join(' ') + projectionTable.search('s1', oversizedQuery, { limit: 5 }) + expect(all).toHaveBeenLastCalledWith( + expect.stringContaining('FROM deepchat_tape_search_projection'), + ['s1', `%${oversizedQuery}%`, `%${oversizedQuery}%`, `%${oversizedQuery}%`, 5] + ) }) it('uses current tape projection without loading full session rows', () => { @@ -1601,6 +1608,57 @@ describe('DeepChatTapeService', () => { ).toBe(JSON.stringify(sources)) }) + itIfSqlite( + `keeps projected and raw linked multi-term search aligned${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, + () => { + const db = new DatabaseCtor(':memory:') + try { + const table = new DeepChatTapeEntriesTable(db) + const projectionTable = new DeepChatTapeSearchProjectionTable(db) + table.createTable() + projectionTable.createTable() + table.ensureBootstrapAnchor('child') + const entry = table.appendEvent({ + sessionId: 'child', + name: 'child/result', + data: { text: 'alpha separated by several words before beta' }, + createdAt: 100 + }) + const sources = [{ sessionId: 'child', maxEntryId: entry.entry_id }] + projectionTable.replaceSession( + 'child', + [ + { + sessionId: 'child', + entryId: entry.entry_id, + kind: 'event', + name: 'child/result', + sourceType: null, + sourceId: null, + sourceSeq: null, + searchText: 'alpha separated by several words before beta', + summaryText: 'alpha separated by several words before beta', + refs: {}, + createdAt: 100 + } + ], + entry.entry_id + ) + + const projected = projectionTable.searchSourcesReadOnly(sources, 'alpha beta', { limit: 5 }) + const raw = table.searchEffectiveSourcesAtHeads(sources, 'alpha beta', { limit: 5 }) + + expect(projected.coveredSources).toEqual(sources) + expect(raw.map((row) => [row.session_id, row.entry_id])).toEqual( + projected.rows.map((row) => [row.session_id, row.entry_id]) + ) + expect(raw).toHaveLength(1) + } finally { + db.close() + } + } + ) + itIfSqlite( `queries effective linked sources and context at frozen heads${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`, () => { @@ -4754,6 +4812,12 @@ describe('DeepChatTapeService', () => { const first = service.linkSubagentTape(input) table.appendEvent({ sessionId: 'child', name: 'child/late', data: { text: 'late' } }) const retry = service.linkSubagentTape(input) + const normalizedRetry = service.linkSubagentTape({ + ...input, + slotId: ' reviewer ', + taskTitle: ' Review ', + resultSummary: ' Done ' + }) expect(first).toEqual({ linkEntry: { sessionId: 'parent', entryId: 2 }, @@ -4763,6 +4827,7 @@ describe('DeepChatTapeService', () => { outcome: 'completed' }) expect(retry).toEqual(first) + expect(normalizedRetry).toEqual(first) const links = entries.filter( (entry) => entry.session_id === 'parent' && entry.name === 'subagent/tape_linked' ) @@ -4782,6 +4847,15 @@ describe('DeepChatTapeService', () => { expect(() => service.linkSubagentTape({ ...input, outcome: 'error' })).toThrow( 'Subagent Tape link conflicts with finalized task run-1/task-1.' ) + expect(() => service.linkSubagentTape({ ...input, slotId: 'writer' })).toThrow( + 'Subagent Tape link conflicts with finalized task run-1/task-1.' + ) + expect(() => service.linkSubagentTape({ ...input, taskTitle: 'Write' })).toThrow( + 'Subagent Tape link conflicts with finalized task run-1/task-1.' + ) + expect(() => service.linkSubagentTape({ ...input, resultSummary: 'Changed' })).toThrow( + 'Subagent Tape link conflicts with finalized task run-1/task-1.' + ) table.ensureBootstrapAnchor('child-2') expect( diff --git a/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts b/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts index 5d35b8af3..f14e88bb7 100644 --- a/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts +++ b/test/main/presenter/sqlitePresenter/deepchatTapeEntriesTable.test.ts @@ -108,6 +108,7 @@ describeIfSqlite('DeepChatTapeEntriesTable', () => { const rebuiltIncarnation = JSON.parse( table.getFirstEntriesBySessions(['s1'])[0].meta_json ).tapeIncarnationId + expect(rebuiltIncarnation).toEqual(expect.any(String)) expect(rebuiltIncarnation).not.toBe(firstIncarnation) db.close()