refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -6,6 +6,6 @@ Product plugins that add model-visible request context without defining a tool.
|
||||
|---|---|---|
|
||||
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
|
||||
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) |
|
||||
|
||||
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## Public API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
|
||||
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `AdditionalContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host injects context and sends or steers the direct message.
|
||||
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host injects context and sends or steers the direct message.
|
||||
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
|
||||
|
||||
## Snapshot semantics
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
|
||||
import {
|
||||
DEFAULT_CANDIDATE_LIMIT,
|
||||
@@ -192,7 +192,7 @@ export class SessionReferenceService extends Service {
|
||||
inputIndex: index,
|
||||
})),
|
||||
}
|
||||
const additionalContext: AdditionalContext = {
|
||||
const additionalContext: UserMessageData = {
|
||||
source,
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/** Public session-reference request, candidate, and preparation records. */
|
||||
|
||||
import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Durable provenance for one prepared cross-session context. */
|
||||
export interface SessionReferenceSource {
|
||||
@@ -53,7 +52,7 @@ export interface PreparedReferencedMessage {
|
||||
/** Readable message content after host mention tokens are removed. */
|
||||
content: ContentBlock[]
|
||||
/** Aggregated untrusted snapshot, absent when the message has no references. */
|
||||
additionalContext?: AdditionalContext
|
||||
additionalContext?: UserMessageData
|
||||
}
|
||||
|
||||
/** Text-only projected conversation item. */
|
||||
|
||||
@@ -18,7 +18,7 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
|
||||
|
||||
@@ -173,9 +173,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const previous = step === 1
|
||||
? precedingMessageTime(agent)
|
||||
: precedingStepContextTime(agent, turn)
|
||||
agent.inject(
|
||||
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
|
||||
{ source: { kind: 'plugin', plugin: name } },
|
||||
)
|
||||
agent.inject({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } })
|
||||
}, { prepend: true })
|
||||
}
|
||||
|
||||
@@ -44,11 +44,8 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
ctx: new Context(),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content, options) {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
send: () => AgentMessageId('stub'),
|
||||
@@ -374,7 +371,7 @@ describe('real agent-loop request history', () => {
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'start' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(laterSawReading).toBe(false)
|
||||
@@ -400,7 +397,7 @@ describe('real agent-loop request history', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'start' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# @deepseek-ai/dsh-workspace-context
|
||||
|
||||
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls.
|
||||
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin injects the initial user-global and project instruction chain into durable history, then discovers nested files and reports later changes or removals after successful filesystem tool calls.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It 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. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
The baseline is injected at the first `agent/step` of each live session. It 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. The durable sourced `user/message` enters the same request as the claimed prompt.
|
||||
|
||||
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.
|
||||
|
||||
@@ -12,7 +12,7 @@ Instruction reads use the optional `ctx.fs` provider. The plugin does not static
|
||||
|
||||
## Prompt Shape
|
||||
|
||||
Baseline instructions are request-only user-role prefix messages framed with the familiar system-reminder pattern:
|
||||
Baseline instructions are durable user-role messages framed with the familiar system-reminder pattern:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
@@ -28,7 +28,7 @@ Instructions from: AGENTS.md
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
Newly reached scopes use a durable raw `context/message`:
|
||||
Newly reached scopes use a durable sourced `user/message`:
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
@@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
|
||||
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
|
||||
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` reaches the model verbatim with no core wrapper.
|
||||
|
||||
## State And Refresh
|
||||
|
||||
@@ -50,7 +50,7 @@ Model-visible text contains no hidden state markers. Each dynamic context event
|
||||
|
||||
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.
|
||||
The initial baseline event itself is not rewritten. Its 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 before its first request. 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 prepares its baseline.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -77,11 +77,11 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Baseline session prefix
|
||||
### Baseline context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
|
||||
|
||||
##### Baseline instruction template
|
||||
|
||||
@@ -101,17 +101,17 @@ Instructions from: AGENTS.md
|
||||
|
||||
#### Token effect
|
||||
|
||||
The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token.
|
||||
Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
|
||||
|
||||
### Newly discovered scope context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained sourced `user/message` with the newly applicable instruction file.
|
||||
|
||||
##### Additional instruction template
|
||||
|
||||
@@ -160,7 +160,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
|
||||
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
|
||||
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
|
||||
|
||||
@@ -97,14 +97,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
{ includeBaselineScopes: false, signal },
|
||||
)
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.context.content, {
|
||||
source: update.context.source,
|
||||
})
|
||||
agent.inject({ content: update.context.content, source: update.context.source })
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
|
||||
agent.inject(baselineMessage.content, { source: { kind: 'plugin', plugin: 'workspace-context' } })
|
||||
agent.inject({ content: baselineMessage.content, source: { kind: 'plugin', plugin: 'workspace-context' } })
|
||||
}
|
||||
baselineLoaded.add(agent.session)
|
||||
})
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* @module @deepseek-ai/dsh-workspace-context/state
|
||||
*/
|
||||
|
||||
import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, UserMessageData } 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'
|
||||
@@ -77,14 +77,11 @@ export interface InstructionVersionUpdate {
|
||||
|
||||
/** Rendered reconciliation plus cache transitions awaiting final policy. */
|
||||
export interface ReconciledInstructionContext {
|
||||
context: WorkspaceAdditionalContext
|
||||
context: UserMessageData
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}
|
||||
|
||||
/** Plugin-owned workspace context. */
|
||||
export type WorkspaceAdditionalContext = AdditionalContext
|
||||
|
||||
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceAdditionalContext {
|
||||
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessageData {
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'workspace-instructions', changes },
|
||||
@@ -329,7 +326,7 @@ export function observeInstructionSessionEvent(
|
||||
*/
|
||||
export function commitPendingInstructionContexts(
|
||||
agent: Agent,
|
||||
contexts: readonly AdditionalContext[] | undefined,
|
||||
contexts: readonly UserMessageData[] | undefined,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
): WorkspaceInstructionChange[] {
|
||||
const committed: WorkspaceInstructionChange[] = []
|
||||
|
||||
@@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
|
||||
it('obeys a probe instruction loaded from the workspace', async () => {
|
||||
const live = await harness()
|
||||
|
||||
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
|
||||
live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(PROBE)
|
||||
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
|
||||
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
|
||||
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
|
||||
|
||||
live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
|
||||
live.agent.followup({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
|
||||
@@ -99,11 +99,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
|
||||
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
|
||||
const live = await harness()
|
||||
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
|
||||
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
|
||||
live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
|
||||
|
||||
live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
|
||||
live.agent.followup({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })
|
||||
await waitForIdle(live.ctx, live.agent)
|
||||
|
||||
const events = [...live.agent.session.events]
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -179,11 +179,8 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
status: 'idle',
|
||||
followup: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject(content, options) {
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
send: () => AgentMessageId('stub'),
|
||||
@@ -204,12 +201,12 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
|
||||
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
|
||||
}
|
||||
|
||||
function workspaceContextOf(result: { additionalContexts?: AdditionalContext[] }): AdditionalContext | undefined {
|
||||
function workspaceContextOf(result: { additionalContexts?: UserMessageData[] }): UserMessageData | undefined {
|
||||
return result.additionalContexts?.find(context =>
|
||||
context.source.kind === 'workspace-instructions')
|
||||
}
|
||||
|
||||
function workspaceChangeContext(scope: string, digest: string): AdditionalContext {
|
||||
function workspaceChangeContext(scope: string, digest: string): UserMessageData {
|
||||
return {
|
||||
content: [{ type: 'text', text: `instructions for ${scope}` }],
|
||||
source: {
|
||||
@@ -219,7 +216,7 @@ function workspaceChangeContext(scope: string, digest: string): AdditionalContex
|
||||
}
|
||||
}
|
||||
|
||||
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: AdditionalContext[] }): number | undefined {
|
||||
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: UserMessageData[] }): number | undefined {
|
||||
let lastSeq: number | undefined
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
lastSeq = agent.session.append('user/message', {
|
||||
@@ -1013,9 +1010,7 @@ describe('workspace context request injection', () => {
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
ctx.on('agent/step', (agent) => {
|
||||
agent.inject([{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], {
|
||||
source: { kind: 'plugin', plugin: 'test-skills' },
|
||||
})
|
||||
agent.inject({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } })
|
||||
})
|
||||
|
||||
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
|
||||
@@ -1514,7 +1509,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => {
|
||||
it('cleans up its agent/step listener when the plugin fiber is disposed', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -1711,13 +1706,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
agent.followup([{ type: 'text', text: 'read and abort' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
expect(agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||||
)).toHaveLength(0)
|
||||
|
||||
agent.followup([{ type: 'text', text: 'retry the read' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
|
||||
Reference in New Issue
Block a user