refactor(agent): remove message metadata channel

This commit is contained in:
_Kerman
2026-07-24 14:05:33 +08:00
parent 7d5c8b12c0
commit 5c7505b208
55 changed files with 1610 additions and 502 deletions

View File

@@ -12,7 +12,7 @@
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
The context uses a typed `{ kind: 'session-reference', ... }` source with `placement: 'prompt-prefix'`. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and source for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration

View File

@@ -9,7 +9,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
@@ -20,7 +20,7 @@ import {
} from './config.ts'
import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts'
import { stringifyTagSafeJson } from './serialization.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts'
export type * from './types.ts'
export type { Config, SessionReferenceErrorCode } from './config.ts'
@@ -181,7 +181,7 @@ export class SessionReferenceService extends Service {
const rendered = this.renderSources(prepared)
const prompt = renderPrompt(rendered.map(source => source.data))
const meta = {
const source: SessionReferenceSource = {
kind: 'session-reference',
version: 1,
references: rendered.map((source, index) => ({
@@ -191,12 +191,11 @@ export class SessionReferenceService extends Service {
...source.stats,
inputIndex: index,
})),
} satisfies JsonValue
}
const context: HookContext = {
source: { kind: 'plugin', plugin: 'session-reference' },
source,
content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
meta,
}
return { content: acceptedContent, contexts: [context] }
}

View File

@@ -4,6 +4,30 @@ import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Durable provenance for one prepared cross-session context. */
export interface SessionReferenceSource {
kind: 'session-reference'
version: 1
references: {
sessionId: string
label: string
capturedThroughSeq: number | null
compacted: boolean
originalMessages: number
retainedMessages: number
omittedMessages: number
omittedBytes: number
truncated: boolean
inputIndex: number
}[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'session-reference': SessionReferenceSource
}
}
/** One source session selected by a host. */
export interface SessionReferenceInput {
/** Opaque source session identity. */

View File

@@ -241,7 +241,7 @@ describe('session reference discovery and preparation', () => {
expect(prepared.contexts).toHaveLength(1)
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' })
expect(context.source).toMatchObject({ kind: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{
@@ -256,7 +256,7 @@ describe('session reference discovery and preparation', () => {
{ role: 'assistant', text: 'visible answer' },
],
}])
expect(context.meta).toMatchObject({
expect(context.source).toMatchObject({
kind: 'session-reference',
version: 1,
references: [{
@@ -354,7 +354,7 @@ describe('session reference discovery and preparation', () => {
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] })
])).resolves.toMatchObject({ contexts: [{ source: { references: [{ label: 'first' }, { label: 'two' }] } }] })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
@@ -432,7 +432,7 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).toContain('checkpoint')
expect(context.content[0].text).toContain('latest-')
expect(context.content[0].text).toContain('omitted')
expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] })
expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] })
})
it('applies the full byte limit independently to each of three references', async () => {
@@ -501,7 +501,6 @@ describe('session reference discovery and preparation', () => {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}],
},
}, { surfaceOp: 'append' })

View File

@@ -46,9 +46,9 @@ The plugin owns the complete `<system-reminder>` framing, and every `context/mes
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
@@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source.
## Model Experience

View File

@@ -99,7 +99,6 @@ export function apply(ctx: Context, config: Config): void {
if (update !== undefined) {
agent.inject(update.context.content, {
source: update.context.source,
meta: update.context.meta,
})
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}

View File

@@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata,
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources,
* while focused pipeline tests own its private pending/cache state transitions.
*/
const install: InvariantInstaller = () => {}

View File

@@ -6,7 +6,7 @@
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
@@ -33,9 +33,20 @@ import {
export const name = 'workspace-context'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
changes: WorkspaceInstructionChange[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'workspace-instructions': WorkspaceInstructionSource
}
}
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
@@ -70,20 +81,14 @@ export interface ReconciledInstructionContext {
versionUpdates: InstructionVersionUpdate[]
}
/** Plugin-owned context with required replay metadata. */
export interface WorkspaceHookContext extends HookContext {
meta: JsonValue
}
/** Plugin-owned workspace context. */
export type WorkspaceHookContext = HookContext
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({
action: change.action,
scope: change.scope,
path: change.path,
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta }
return {
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes },
}
}
/**
@@ -103,20 +108,20 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'plugin'
&& 'plugin' in source && source.plugin === name
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)
}
function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
function workspaceInstructionChanges(source: unknown): WorkspaceInstructionChange[] {
if (!isWorkspaceContextSource(source)) return []
const changes: WorkspaceInstructionChange[] = []
for (const value of meta.changes) {
for (const value of source.changes) {
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
@@ -146,7 +151,7 @@ function visibleInstructionChanges(
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
const changes = workspaceInstructionChanges(event.data.source)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
@@ -283,7 +288,7 @@ export function observeInstructionSessionEvent(
switch (event.type) {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
for (const change of workspaceInstructionChanges(event.data.source)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
@@ -329,7 +334,7 @@ export function commitPendingInstructionContexts(
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
const changes = workspaceInstructionChanges(context.source)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {

View File

@@ -108,11 +108,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
&& event.data.source.kind === 'workspace-instructions')
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'user/message'

View File

@@ -184,7 +184,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
@@ -206,16 +205,14 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined {
return result.additionalContexts?.find(context =>
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
context.source.kind === 'workspace-instructions')
}
function workspaceChangeContext(scope: string, digest: string): HookContext {
return {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }],
},
}
@@ -227,7 +224,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H
lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
return lastSeq
@@ -930,7 +926,7 @@ describe('workspace context request injection', () => {
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
@@ -1050,7 +1046,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
@@ -1079,7 +1075,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
@@ -1810,19 +1806,18 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
version: 1,
changes: [{
action: 'set',
scope: sk('pkg', 'AGENTS.md'),
path: join('pkg', 'AGENTS.md'),
}],
})
const meta = workspaceContextOf(result)?.meta
const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes[0]
const source = workspaceContextOf(result)?.source
const firstChange = source?.kind === 'workspace-instructions'
? source.changes[0]
: undefined
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
? firstChange.digest
@@ -1901,9 +1896,9 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
const meta = workspaceContextOf(result)?.meta
const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes
const source = workspaceContextOf(result)?.source
const changes = source?.kind === 'workspace-instructions'
? source.changes
: []
expect(changes).toEqual(expect.arrayContaining([
expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }),
@@ -2122,7 +2117,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(changed)?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -2168,7 +2163,7 @@ describe('dynamic nested workspace context injection', () => {
})
// Removing one candidate only removes its own scope; the sibling scope is untouched.
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2196,7 +2191,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
const text = blocksText(workspaceContextOf(result)?.content)
@@ -2276,7 +2271,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }],
})
expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
@@ -2310,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') },
@@ -2347,9 +2342,8 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toEqual({
expect(workspaceContextOf(removed)?.source).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
@@ -2394,7 +2388,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2433,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
expect(workspaceContextOf(restored)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
@@ -2537,7 +2531,7 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
@@ -2691,31 +2685,23 @@ describe('dynamic nested workspace context injection', () => {
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
{ action: 'set', scope: 'pkg', path: 42 },
{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 },
],
},
} as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
source: { kind: 'workspace-instructions', changes: 'invalid' } as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }],
},
}, { surfaceOp: 'append' })
const result = await ctx.tools.execute({
@@ -2884,8 +2870,8 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3246,7 +3232,6 @@ describe('workspace context pending state', () => {
const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
@@ -3255,7 +3240,6 @@ describe('workspace context pending state', () => {
const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)