fix(workspace-context): commit projections after tool steps
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
|
||||
README.md: 82aee27a8fbd6a1ab0f0860226b081e28ba72e6e
|
||||
README.zh.md: 9d983f95f4e018cb8fe983d9862cf5f31f83c5f2
|
||||
README.md: 7ab21bbf8c72f8424bc8d4fdad9153c7ed8bb7e9
|
||||
README.zh.md: 7ad68759ab811cdc5e848fd686c7fad612c9b6d9
|
||||
|
||||
@@ -8,7 +8,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
|
||||
|
||||
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
The plugin also observes immutable `tools/result` outcomes for successful first-party `read`, `write`, and `edit` calls. Each accepted touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file queues an addition in the agent inbox; a changed file queues a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate queues a removal notice. Native calls and Code Mode sub-dispatches share this path: nested touches bubble through opaque parent execution tokens until the top-level result settles, and touches produced inside an agent-loop step do not begin their asynchronous projection until the durable `step/end`. Direct tool executions outside an open step project immediately. This preserves tool-call/result/step adjacency without depending on filesystem timing. Discovery follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
|
||||
|
||||
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
|
||||
|
||||
@@ -48,7 +48,7 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
|
||||
|
||||
## State And Refresh
|
||||
|
||||
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. 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. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that 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 baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
|
||||
|
||||
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.
|
||||
|
||||
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
|
||||
|
||||
该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
该插件还会观察第一方 `read`、`write` 和 `edit` 调用成功后产生的不可变 `tools/result`。每个已接受的 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件会在 agent inbox 中排入一项新增;已改变文件会排入一项替换;文件消失或成为同一目录中较早候选文件的重复项时,会排入一则移除通知。原生调用与 Code Mode 子分派共享该路径:嵌套 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定;在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影。打开的步骤之外直接执行工具时,则立即投影。这样无需依赖文件系统时序,也能保持工具调用/结果/步骤的相邻关系。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
|
||||
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并对解析结果执行 stat,因此会跟随路径最后一段的 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
|
||||
|
||||
@@ -48,7 +48,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
## 状态与刷新
|
||||
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
|
||||
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
|
||||
|
||||
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟到外层 `run_code` 结果之后。
|
||||
每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
|
||||
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
@@ -85,15 +85,22 @@ export function apply(ctx: Context, config: Config): void {
|
||||
excludedScopes: ReadonlySet<string>
|
||||
}>()
|
||||
const projectionLifecycle = new AbortController()
|
||||
type ProjectionTouch = { agent: Agent; path: string }
|
||||
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('workspace-context disposed'))
|
||||
executionTouches.clear()
|
||||
},
|
||||
'workspace-context.projectionLifecycle',
|
||||
)
|
||||
// Emit listeners are not awaited, so each projection must compose against the
|
||||
// inbox produced by earlier file results for the same agent.
|
||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||
// Execution ancestry and the enclosing durable step are the two commit
|
||||
// boundaries before an asynchronous projection may mutate the agent inbox.
|
||||
const openSteps = new WeakMap<Session, boolean>()
|
||||
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
|
||||
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
@@ -272,6 +279,46 @@ export function apply(ctx: Context, config: Config): void {
|
||||
while ((projection = projectionTails.get(agent)) !== undefined) await projection
|
||||
}
|
||||
|
||||
const stepIsOpen = (session: Session): boolean => {
|
||||
const known = openSteps.get(session)
|
||||
if (known !== undefined) return known
|
||||
let open = false
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'step/start') open = true
|
||||
else if (event.type === 'step/end' || event.type === 'turn/end') open = false
|
||||
}
|
||||
openSteps.set(session, open)
|
||||
return open
|
||||
}
|
||||
|
||||
const projectTouch = (touch: ProjectionTouch): void => {
|
||||
const session = touch.agent.session
|
||||
if (!stepIsOpen(session)) {
|
||||
queueProjection(touch.agent, touch.path)
|
||||
return
|
||||
}
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) stepTouches.set(session, [touch])
|
||||
else pending.push(touch)
|
||||
}
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'step/start') {
|
||||
openSteps.set(session, true)
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
openSteps.set(session, false)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'step/end') return
|
||||
openSteps.set(session, false)
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) return
|
||||
stepTouches.delete(session)
|
||||
for (const touch of pending) queueProjection(touch.agent, touch.path)
|
||||
})
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, messages, step, signal },
|
||||
next,
|
||||
@@ -301,9 +348,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath === undefined) return
|
||||
queueProjection(exec.agent, ownPath)
|
||||
const touches = executionTouches.get(exec.token) ?? []
|
||||
executionTouches.delete(exec.token)
|
||||
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
if (touches.length > 0) {
|
||||
const parentTouches = executionTouches.get(exec.parent)
|
||||
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
|
||||
else parentTouches.push(...touches)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const touch of touches) projectTouch(touch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,9 +187,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function stubToolExecution(input: Omit<ToolExecution, 'token'>): ToolExecution {
|
||||
function stubToolExecution(
|
||||
input: Omit<ToolExecution, 'token'> & { token?: ToolExecutionToken },
|
||||
): ToolExecution {
|
||||
return {
|
||||
token: Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||||
token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
@@ -3948,6 +3950,106 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('defers a nested file projection until the enclosing step commits', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||||
const agent = stubAgent(root)
|
||||
const turnStart = agent.session.append('turn/start', { turn: 1 })
|
||||
ctx.emit('session/event', agent.session, turnStart)
|
||||
const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.emit('session/event', agent.session, stepStart)
|
||||
const outerToken = Symbol('outer-code-run') as ToolExecutionToken
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('nested-read') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('nested-read'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'file.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('nested-non-file') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('nested-non-file'),
|
||||
name: 'search',
|
||||
arguments: {},
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('second-nested-read') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('second-nested-read'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'second.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('outer-code-run'),
|
||||
name: 'run_code',
|
||||
arguments: {},
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
expect(agent.inbox.nextStep).toEqual([])
|
||||
|
||||
const stepEnd = agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
ctx.emit('session/event', agent.session, stepEnd)
|
||||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||||
.toContain('nested package rule')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds closed step state from existing session history', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||||
const agent = stubAgent(root)
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-closed-step'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'file.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
|
||||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||||
.toContain('nested package rule')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores failed, aborted, agentless, and non-file final results', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user