Merge branch 'codex/simp-hide-concrete-agent-loop' into codex/simp-hide-subagent-internals

This commit is contained in:
Tianyi Cui
2026-07-16 00:53:02 +08:00
7 changed files with 37 additions and 25 deletions

View File

@@ -401,8 +401,7 @@ describe('Agent', () => {
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles
// it. Regression for the round-3 whenIdle finding.
// hung the promise. With internal waiters, the fiber disposer still settles it.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent

View File

@@ -44,7 +44,7 @@ function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('HIGH: session log records what agent/step-result actually produced', () => {
describe('session log records what agent/step-result actually produced', () => {
it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => {
const adapter = new MockAdapter([textResponse('original'), textResponse('done')])
const ctx = await harness(adapter)
@@ -94,7 +94,7 @@ describe('HIGH: session log records what agent/step-result actually produced', (
})
})
describe('HIGH: abort during tool execution ends the turn', () => {
describe('abort during tool execution ends the turn', () => {
it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
@@ -146,7 +146,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
})
})
describe('HIGH: steering from late extension points is never stranded', () => {
describe('steering from late extension points is never stranded', () => {
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
const adapter = new MockAdapter([
textResponse('no tools, would stop here'),
@@ -268,7 +268,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
})
})
describe('HIGH: plugin exceptions are contained', () => {
describe('plugin exceptions are contained', () => {
it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
@@ -323,7 +323,7 @@ describe('HIGH: plugin exceptions are contained', () => {
})
})
describe('MEDIUM: disposed status is part of the agent/status contract', () => {
describe('disposed status is part of the agent/status contract', () => {
it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -370,7 +370,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
})
})
describe('MEDIUM: misc registry and config fixes', () => {
describe('misc registry and config fixes', () => {
it('duplicate adapter registration is rejected', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -529,7 +529,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
})
})
describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
describe('turn numbering continues across seeded (forked) sessions', () => {
it('a forked agent continues turn numbers after the seed log', async () => {
const first = new MockAdapter([textResponse('turn one')])
const ctx = await harness(first)
@@ -567,7 +567,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
})
})
describe('LOW: discriminated SessionEvent narrows without casts', () => {
describe('discriminated SessionEvent narrows without casts', () => {
it('narrows event.data from event.type', () => {
const session = new Session(SessionId('s'))
const appended: SessionEvent = session.append('tool/call', {
@@ -585,7 +585,7 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => {
})
})
describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
describe('a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// The second sanctioned adapter error path (besides throwing): an
// adapter that cannot throw mid-stream ends the stream with a

View File

@@ -257,6 +257,7 @@ describe('CreateWizard and scaffolder', () => {
expect(index).toContain('SdkBootContext')
expect(index).toContain('ctx.agents.create')
expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
expect(index).not.toContain('AgentId')
const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
expect(tsconfig.compilerOptions.types).toEqual(['node'])

View File

@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-helper/features/builtin/app
*/
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
import { featureId } from '../../ids.ts'
import type { ProjectProfile } from '../../project/types.ts'
import {
@@ -94,11 +95,11 @@ class AppOption extends FeatureOption {
name: '@deepseek-ai/dsh-stdio',
config: {
welcome: 'agent REPL ready. Give it a coding task.',
agent: 'main',
sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'),
},
}, ['welcome', 'agent'], config => [
}, ['welcome', 'sessionId'], config => [
...optionalString(config, 'welcome'),
...requiredString(config, 'agent'),
...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'),
]),
])
case 'embed':

View File

@@ -2,14 +2,12 @@
import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
{{else}}
import { randomUUID } from 'node:crypto'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
{{/if}}
/** Boot this project's cordis.yml when invoked by dsh-scripts. */
export async function main(boot: SdkBootContext) {
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
{{#if isStdio}}
const model = boot.args.model
if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=<name>')
@@ -17,24 +15,26 @@ export async function main(boot: SdkBootContext) {
if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) {
throw new Error('stdio startup requires --resume=<session-id>')
}
const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)
process.env.DSH_SDK_SESSION_ID = sessionId
{{/if}}
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
{{#if isStdio}}
if (resume === undefined) {
await ctx.agents.create({
agentId: AgentId('main'),
sessionId: SessionId(`main-session-${randomUUID()}`),
sessionId,
meta: { cwd: boot.cwd },
agentOptions: { model },
})
} else {
await ctx.agents.resume({
agentId: AgentId('main'),
resumeSessionId: SessionId(resume),
resumeSessionId: sessionId,
agentOptions: { model },
})
}
{{else}}
{{#if isEmbed}}
await ctx.agents.create({
agentId: AgentId('main'),
sessionId: SessionId(`main-session-${randomUUID()}`),
meta: { cwd: boot.cwd },
agentOptions: { model: {{modelLiteral}} },

View File

@@ -167,6 +167,10 @@ describe('SdkProject and ProjectEditSession', () => {
expect(index).toContain('SdkBootContext')
expect(index).toContain('agents.create')
expect(index).toContain('boot.args.resume')
expect(index).not.toContain('AgentId')
expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)')
expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId')
expect(index).toContain('resumeSessionId: sessionId')
expect(project.packageManifest().scripts).toEqual({
dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
build: 'dsh-sdk build',
@@ -175,7 +179,11 @@ describe('SdkProject and ProjectEditSession', () => {
config: 'dsh-sdk config',
})
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
expect(project.cordis.entry('stdio')?.config).toMatchObject({ agent: 'main' })
expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({
source: 'process.env.DSH_SDK_SESSION_ID',
})
expect(await readFile(join(project.root, 'cordis.yml'), 'utf8'))
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model')
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
@@ -286,7 +294,10 @@ describe('SdkProject and ProjectEditSession', () => {
const embed = (await embedEdit.commit()).project
expect(embed.profile.runInterface).toBe('embed')
expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness')
expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create')
const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8')
expect(embedIndex).toContain('agents.create')
expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'")
expect(embedIndex).not.toContain('AgentId')
await writeFile(join(embed.root, 'README.md'), '# Custom README\n')
const modified = await SdkProject.open(embed.root)

View File

@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
The plugin injects `agents`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
## Multi-session
Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
## Session config options