Fix workspace context review findings

This commit is contained in:
Yichen Jiang
2026-07-13 16:31:03 +08:00
parent adf6b8a1ab
commit aa62b5109a
40 changed files with 708 additions and 252 deletions

View File

@@ -59,7 +59,7 @@ forever:
TURN (error-contained):
'turn/start'
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
inject additionalContext) | block (→ session('prompt/blocked'), drop)
inject each additionalContexts entry) | block (→ session('prompt/blocked'), drop)
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
STEP loop:
drain steering

View File

@@ -155,7 +155,7 @@ export interface LoopHandle {
* wait for queued messages (idle)
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
* allow → session('user/message'…) (+ inject additionalContexts) | block → drop
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
@@ -406,13 +406,14 @@ async function runTurn(
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = decision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// `allow.additionalContext` is a SEPARATE context/message the next request
// also sees. The turn is open, so inject() appends it into THIS turn.
if (decision.additionalContext) {
agent.inject(decision.additionalContext.content, {
source: decision.additionalContext.source,
...decision.additionalContext.envelope !== undefined ? { envelope: decision.additionalContext.envelope } : {},
...decision.additionalContext.meta !== undefined ? { meta: decision.additionalContext.meta } : {},
// Every `allow.additionalContexts` entry is a separate context/message the
// next request also sees. The turn is open, so inject() appends each one
// into THIS turn without flattening provenance, framing, or metadata.
for (const context of decision.additionalContexts ?? []) {
agent.inject(context.content, {
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
})
}
}

View File

@@ -17,7 +17,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, the reshaped `agent/turn-continuation`
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
* split with `additionalContext` buffering. These verify the canonical event
* split with `additionalContexts` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
* external protocol — a native plugin uses the typed decisions directly.
*/
@@ -91,7 +91,7 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContext injects a separate context/message into the turn', async () => {
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -100,12 +100,12 @@ describe('agent/prompt-submit', () => {
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
additionalContext: {
additionalContexts: [{
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta,
},
}],
}))
send(agent, 'go')
@@ -124,7 +124,7 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx')
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
@@ -141,7 +141,7 @@ describe('agent/prompt-submit', () => {
({
kind: 'allow',
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
// The pre-step seam (where compaction lives) derives the surface it would act