Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md
#	apps/web/tests/seeded-history.e2e.ts
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/core-data-structures/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/tests/history-fold.spec.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
#	packages/context/workspace-context/src/index.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/skill/tool-skill/README.i18n.yaml
#	packages/skill/tool-skill/README.md
#	packages/skill/tool-skill/README.zh.md
#	packages/skill/tool-skill/src/index.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
This commit is contained in:
creatixchu
2026-08-06 11:28:48 +08:00
823 changed files with 18562 additions and 14742 deletions

View File

@@ -5,10 +5,10 @@ import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -44,17 +44,14 @@ function agentForCwd(cwd: string): Agent {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
acceptsNextStep: false,
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -64,24 +61,21 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
id: SessionId(id),
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'running',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
reserveTurnAdmission: () => undefined,
steer: () => {},
inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn = 1): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
@@ -89,7 +83,32 @@ function openMessageTurn(session: Session, turn = 1): void {
}
async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal)
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
}
async function proposeStep(
ctx: Context,
agent: Agent,
messages: UserMessage[],
): Promise<PreStepDecision> {
const signal = new AbortController().signal
return await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
messages,
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages }),
)
}
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
@@ -117,7 +136,17 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro
}
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal)
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
[],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
return agent.session.deriveMessages()
}
@@ -212,13 +241,30 @@ describe('dsh-tool-skill', () => {
source: 'runtime',
content: 'User-only body.',
})
ctx.on('agent/step', (agent) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } }))
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
const decision = await next()
if (decision.kind === 'reject') return decision
return {
...decision,
messages: [
...decision.messages,
createUserMessage({
content: [{ type: 'text', text: 'later contribution' }],
source: { kind: 'plugin', plugin: 'later-contribution' },
}),
],
}
})
const prefix = await composePrefix(ctx, '/workspace')
expect(prefix).toEqual([
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'later contribution' }],
source: { kind: 'plugin', plugin: 'later-contribution' },
},
{
id: expect.any(String) as unknown,
role: 'user',
@@ -248,14 +294,8 @@ describe('dsh-tool-skill', () => {
].join('\n'),
}],
},
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'later contribution' }],
source: { kind: 'plugin', plugin: 'later-contribution' },
},
])
const rendered = JSON.stringify(prefix[0])
const rendered = JSON.stringify(prefix[1])
expect(rendered).not.toContain('whenToUse')
expect(rendered).not.toContain('secret-source')
expect(rendered).not.toContain('/secret/path')
@@ -325,6 +365,88 @@ describe('dsh-tool-skill', () => {
expect(catalogMessages(session)).toEqual([])
})
it('deduplicates or replaces a catalog already proposed for the same step', async () => {
const home = await tempDir('tool-proposed-catalog')
const ctx = await setup(home)
const disposeFirst = ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = Session.create(SessionId('proposed-catalog'))
const agent = sessionAgent(session)
openMessageTurn(session)
await fireStep(ctx, agent, 1, 1)
const initial = catalogMessages(session)[0]?.data
if (initial === undefined) throw new Error('expected initial catalog')
const duplicate = await proposeStep(ctx, agent, [initial])
expect(duplicate).toEqual({ kind: 'enter', messages: [] })
ctx.skills.register({
name: 'second-skill',
description: 'Second skill',
source: 'runtime',
content: 'Second body.',
})
const companion = createUserMessage({
content: [{ type: 'text', text: 'keep this message' }],
source: { kind: 'user' },
})
const replaced = await proposeStep(ctx, agent, [companion, initial])
expect(replaced.kind).toBe('enter')
if (replaced.kind === 'reject') throw new Error('expected catalog replacement')
expect(replaced.messages).toHaveLength(2)
expect(replaced.messages[0]).toBe(companion)
expect(replaced.messages[1]?.id).not.toBe(initial.id)
expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill')
disposeFirst()
})
it('removes a stale proposed catalog before the first empty baseline', async () => {
const home = await tempDir('tool-proposed-empty-catalog')
const ctx = await setup(home)
const session = Session.create(SessionId('proposed-empty-catalog'))
const stale = createUserMessage({
content: catalogContent(['- `stale-skill`: Stale skill']),
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'stale-skill', description: 'Stale skill' }],
},
})
const decision = await proposeStep(ctx, sessionAgent(session), [stale])
expect(decision).toEqual({ kind: 'enter', messages: [] })
})
it('keeps a proposed catalog that already matches the current snapshot', async () => {
const home = await tempDir('tool-matching-proposal')
const ctx = await setup(home)
ctx.skills.register({
name: 'first-skill',
description: 'First skill',
source: 'runtime',
content: 'First body.',
})
const session = Session.create(SessionId('matching-proposal'))
const proposed = createUserMessage({
content: catalogContent(['- `first-skill`: First skill']),
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'first-skill', description: 'First skill' }],
},
})
const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
})
it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
const home = await tempDir('tool-dynamic-catalog')
const ctx = await setup(home)