refactor(agent): remove message metadata channel
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user