Merge branch 'master' into sdk/ts-client-and-subagent

This commit is contained in:
Tianyi Cui
2026-07-27 22:41:23 +08:00
committed by GitHub
386 changed files with 7849 additions and 9915 deletions

View File

@@ -131,10 +131,16 @@ export class HarnessSdkServer {
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
// An agent-loop-only reload disposes the loop's agents while this record
// survives; a retained agent accepts followup() silently, so validate the
// record against the live registry before delivery (as the ACP bridge does).
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
}
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.followup(params.contentBlocks)
rec.handle.agent.followup({ content: params.contentBlocks, source: { kind: 'user' } })
await rec.handle.agent.whenIdle()
const payload: SessionFinishedNotification = {
sessionId: params.sessionId,

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
@@ -153,7 +153,7 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
})
orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
@@ -173,21 +173,24 @@ describe('HarnessSdkServer', () => {
.mockResolvedValue(undefined)
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
const mainAgent = ({
id: SessionId('main'),
followup: mainFollowup,
whenIdle: mainWhenIdle,
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
const otherAgent = ({
id: SessionId('other'),
followup: otherFollowup,
whenIdle: vi.fn(() => Promise.resolve()),
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
const create = vi.fn(async (options: { sessionId: SessionId }) =>
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
@@ -216,9 +219,43 @@ describe('HarnessSdkServer', () => {
expect(otherHandle.dispose).toHaveBeenCalledOnce()
})
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
const followup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('stub'))
const agent = ({
id: SessionId('zombie'),
followup,
whenIdle: vi.fn(() => Promise.resolve()),
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
// The registry drops the agent after creation, modelling an agent-loop-only
// reload that leaves the server's SessionRecord pointing at a detached agent.
let live = true
const ctx = {
on: vi.fn(() => () => undefined),
agents: {
create: vi.fn(async () => handle),
get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
},
get: () => undefined,
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport())
const prompt = (text: string) => server.prompt({
sessionId: 'zombie',
contentBlocks: [{ type: 'text', text }],
})
await expect(prompt('while live')).resolves.toEqual({ accepted: true })
live = false
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
// The detached agent was never driven by the rejected prompt.
expect(followup).toHaveBeenCalledOnce()
await server.shutdown()
})
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport) as unknown as {
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
@@ -227,16 +264,14 @@ describe('HarnessSdkServer', () => {
}
const session = ctx.sessions.create(SessionId('message-outcome'))
const agent = ({
id: SessionId('message-outcome'),
session,
followup(content: { type: 'text'; text: string }[]) {
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
trigger: { kind: 'message', source: input.source },
})
session.append('user/message', {
content,
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('user/message', input, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 2,
@@ -250,7 +285,8 @@ describe('HarnessSdkServer', () => {
return AgentMessageId('message-outcome')
},
whenIdle: () => Promise.resolve(),
} satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
ctx.agents.register(agent)
server.sessions.set('message-outcome', {
handle: { agent, dispose: () => Promise.resolve() },
lastTurnEnd: undefined,