Merge remote-tracking branch 'origin/master' into session-query-tool

# Conflicts:
#	docs/architecture.i18n.yaml
This commit is contained in:
Hypatia May
2026-07-24 20:59:18 +08:00
265 files changed
+5709 -1503

No files matched your search

+13 -3
View File
@@ -4,7 +4,7 @@
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { mkdir, stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
@@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const sessionId = `session-${randomUUID()}` as SessionId
// A session's cwd is its project path. When the creator does not choose
// one, the default project is the host-level default (the host process
// working directory unless boot overrides it).
// working directory unless boot overrides it). Ensure the directory
// exists so Create-workspace and typed paths land on a real folder.
const cwd = request.payload.cwd ?? defaults.cwd
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
return err(request, {
code: 'internal',
message: `failed to ensure project directory "${cwd}": ${String(error)}`,
details: {},
})
}
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
return ok(request, { sessionId: handle.agent.id })
},
@@ -437,7 +447,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (mode === 'steer') agent.steer(content, { source })
else agent.send(content, { source })
else agent.followup(content, { source })
} catch (error: unknown) {
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -249,6 +249,29 @@ describe('sessions.create / list', () => {
expect(first?.running).toBe(false)
expect(first?.parentSessionId).toBeUndefined()
})
it('ensures a missing project directory before minting the session', async () => {
const { api } = await boot()
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-'))
const cwd = join(root, 'nested', 'workspace')
expect(existsSync(cwd)).toBe(false)
const { sessionId } = expectOk(await api.sessions.create(request({ cwd })))
expect(existsSync(cwd)).toBe(true)
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd)
})
it('fails loud when the project directory cannot be created', async () => {
const { api } = await boot()
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-'))
const blocker = join(root, 'file-not-dir')
writeFileSync(blocker, 'x')
const response = await api.sessions.create(request({ cwd: join(blocker, 'child') }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('expected mkdir failure')
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/failed to ensure project directory/)
})
})
describe('sessions.prompt / cancel', () => {
@@ -390,7 +413,7 @@ describe('sessions.prompt / cancel', () => {
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
agent.send([{ type: 'text', text: 'run forever' }])
agent.followup([{ type: 'text', text: 'run forever' }])
expectOk(await api.sessions.cancel(request({ sessionId })))
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
@@ -409,7 +432,7 @@ describe('sessions.history', () => {
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
const agent = first.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
agent.followup([{ type: 'text', text: 'save me' }])
await idle
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
await first.dispose()
@@ -458,7 +481,7 @@ describe('sessions.history', () => {
const agent = ctx.agents.get(sessionId) as Agent
for (const text of ['q1', 'q2', 'q3']) {
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text }])
agent.followup([{ type: 'text', text }])
await idle
}
@@ -529,7 +552,7 @@ describe('events streams', () => {
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'go' }])
agent.followup([{ type: 'text', text: 'go' }])
await idle
const live = await stream.next()
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
@@ -592,7 +615,7 @@ describe('events streams', () => {
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'run' }])
agent.followup([{ type: 'text', text: 'run' }])
await idle
const runningFrame = await stream.next()
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })