Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/request-recovery.spec.ts
#	packages/core/agent/src/types.ts
#	packages/core/scope/tests/invariant.spec.ts
#	packages/llm/llm-retry/README.i18n.yaml
#	packages/llm/llm-retry/README.md
#	packages/llm/llm-retry/README.zh.md
#	packages/llm/llm-retry/src/index.ts
#	packages/llm/llm-retry/src/invariant.ts
#	packages/llm/llm-retry/tests/invariant.spec.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/plan/plan-mode/src/index.ts
#	packages/plan/plan-mode/tests/integration.spec.ts
#	packages/plan/plan-mode/tests/plan-mode.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 23:29:26 +08:00
461 changed files with 8294 additions and 10405 deletions

View File

@@ -19,7 +19,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
return 'max_tokens'
case 'aborted':
case 'disposed':
case 'rejected':
case 'interrupted':
return 'cancelled'
case 'error':

View File

@@ -77,6 +77,12 @@ interface SessionRecord {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
/**
* A failed turn's terminal reason, held until quiescence: a retry action
* closes the failed turn and opens a successor that adopts the prompt, so
* rejecting at `turn/end` would race the recovery.
*/
pendingError: Extract<TurnEndReason, { kind: 'error' }> | undefined
} | undefined
}
@@ -125,15 +131,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
inflight.resolve(reason)
}
const settleFromTurnEnd = (
const rejectFromError = (
inflight: NonNullable<SessionRecord['inflight']>,
reason: TurnEndReason,
reason: Extract<TurnEndReason, { kind: 'error' }>,
): void => {
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
return
}
inflight.resolve(turnEndToStopReason(reason))
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
@@ -162,10 +164,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'user') {
inflight.turn = event.data.turn
} else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') {
// A recovery policy opened a retry turn on the failed history: the
// prompt rides it instead of rejecting on the failed turn's end.
inflight.turn = event.data.turn
inflight.pendingError = undefined
}
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
record.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
if (event.data.reason.kind === 'error') {
// Hold the rejection: request recovery may adopt the prompt with a
// successor turn; quiescence without one delivers this error.
inflight.turn = undefined
inflight.pendingError = event.data.reason
} else {
record.inflight = undefined
inflight.resolve(turnEndToStopReason(event.data.reason))
}
}
}
})
@@ -243,23 +257,47 @@ export function apply(ctx: Context, config: AcpConfig): void {
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) throw invalidParams('empty prompt')
// Not driving a retired agent is this bridge's contract: an
// agent-loop-only reload disposes the loop's agents while the bridge
// record survives, so validate the record against the live registry
// before sending — a disposed machine would accept the item silently.
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const stopReason = await new Promise<StopReason>((resolve, reject) => {
// Arm the slot before followup() so a listener-driven synchronous
// turn cannot slip past correlation; a synchronous followup()
// failure (an agent disposed outside the bridge, e.g. an
// agent-loop-only reload) must free the slot again or the session
// failure (invalid input) must free the slot again or the session
// would reject every later prompt as already in flight.
record.inflight = { resolve, reject, turn: undefined }
const inflight: NonNullable<SessionRecord['inflight']> = {
resolve, reject, turn: undefined, pendingError: undefined,
}
record.inflight = inflight
try {
record.agent.followup([{ type: 'text', text }])
record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
// The machine's send() contains listener failures and accepts
// any typed input; this guards a future synchronous throw so the
// slot cannot wedge.
/* v8 ignore start -- future-proofing guard, see above */
} catch (error: unknown) {
record.inflight = undefined
// followup() throws only Errors (disposed agent / invalid input);
// the String arm is a defensive fallback for a non-Error throw.
/* v8 ignore next */
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
/* v8 ignore stop */
// Admission is pre-turn and retries outlive their failed turn, so a
// turnless slot settles only at quiescence: a held failure rejects
// (no retry adopted the prompt); no turn at all means admission
// discarded the prompt — report cancelled.
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight || inflight.turn !== undefined) return
record.inflight = undefined
if (inflight.pendingError !== undefined) {
rejectFromError(inflight, inflight.pendingError)
return
}
inflight.resolve('cancelled')
})
})
return { stopReason }
},

View File

@@ -9,7 +9,6 @@ describe('ACP automation codec', () => {
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'disposed' }, 'cancelled'],
[{ kind: 'rejected', reason: 'blocked' }, 'cancelled'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
]

View File

@@ -21,7 +21,7 @@ describe('ACP connection ownership', () => {
await harness.acpFiber.dispose()
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
@@ -44,7 +44,7 @@ describe('ACP connection ownership', () => {
await harness.closeClientTransport()
await harness.acpFiber.dispose()
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
})
@@ -58,10 +58,10 @@ describe('ACP connection ownership', () => {
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.abortClientTransport()
await vi.waitFor(() => { expect(agent.status).toBe('disposed') })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
})
expect(agent.status).toBe('idle')
})
it('disconnect and plugin disposal share one quiescence boundary', async () => {
@@ -73,7 +73,7 @@ describe('ACP connection ownership', () => {
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()])
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})

View File

@@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => {
sessionId: SessionId('foreign'),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(harness.updates).toHaveLength(0)
})

View File

@@ -48,7 +48,7 @@ describe('ACP prompt lifecycle', () => {
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: plugin pre-step failed/)
@@ -72,7 +72,7 @@ describe('ACP prompt lifecycle', () => {
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })
}
})
@@ -166,4 +166,40 @@ describe('ACP prompt lifecycle', () => {
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
})
it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
// A recovery policy: schedule one retry for the failed request.
let retried = false
harness.ctx.on('agent/request-error', async (_subject) => {
if (!retried) {
retried = true
return { kind: 'retry' }
}
})
const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
})
it('a failed turn with no retry still rejects, at quiescence', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
let offered = 0
harness.ctx.on('agent/request-error', async () => { offered += 1 })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: terminal boom/)
expect(offered).toBe(1)
})
it('an admission-blocked prompt settles cancelled instead of hanging', async () => {
harness = await makeBridgeHarness({ script: [] })
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' }))
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'cancelled' })
// The blocked prompt opened no turn and streamed nothing.
expect(messageText(harness)).toBe('')
})
})

View File

@@ -111,11 +111,12 @@ describe('bash tool through the agent loop', () => {
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.followup([{ type: 'text', text: 'inspect the current session' }])
agent.followup({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
await ctx.sessions.flush(agent.session)
expect(existsSync(location!.path)).toBe(true)
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
@@ -130,7 +131,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
agent.followup({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const log = events(agent)
@@ -162,7 +163,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'run exit 9' }])
agent.followup({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result')
@@ -182,7 +183,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
agent.followup({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result')
@@ -202,7 +203,7 @@ describe('bash tool through the agent loop', () => {
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
// The next turn collects the output through the generic task tool.
agent.followup([{ type: 'text', text: 'collect it' }])
agent.followup({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.isError).toBe(false)

View File

@@ -87,7 +87,6 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A tool result paired (when in-window) with its call head. */

View File

@@ -46,7 +46,6 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
}
return {

View File

@@ -20,7 +20,8 @@ const rid = (id: string): RpcId => id as RpcId
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
return {
type: 'session/queued', sessionId: SID, content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never, steering,
source: { kind: 'user', rpcId: rid(rpcId) } as never,
steering,
}
}
@@ -41,7 +42,8 @@ describe('queue intake', () => {
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued', sessionId: SID,
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' }, steering: false,
source: { kind: 'plugin', plugin: 'loop' },
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
})
@@ -85,22 +87,22 @@ describe('queue retirement (host queuedMirror rules)', () => {
it('steering/message drains the source-matched steering row only', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true))
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true))
// Loop-authored steering (different source) must not consume the user entry.
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering })
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering })
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
})
@@ -108,7 +110,7 @@ describe('queue retirement (host queuedMirror rules)', () => {
const session = makeSession()
session.handleRunning(true)
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true))
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2'))
session.handleRunning(false)
expect(session.getSnapshot().queue).toEqual([])
})
@@ -144,6 +146,19 @@ describe('queue reconnect semantics', () => {
await session.resync()
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
})
it('replayed steering retires without a replayed turn/start', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])
})
})
describe('manager buffering of queued frames', () => {

View File

@@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
}
function rowLabels(): string[] {
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!)
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
}
describe('PopupSelectView', () => {

View File

@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
seq: number
onOpenDetails: OpenDetails
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
{subCalls.map((node) => (
{subCalls.map(node => (
<SubCallRow
key={node.callId}
renderSlot={renderSlot}
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
}) {
return (
<div className={css.toolGroup}>
{results.map((node) => (
{results.map(node => (
<CallRow
key={node.callId}
renderSlot={renderSlot}
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
useSession: UseConversation
onGrow: () => void
}) {
const partial = useSession((s) => s.partial)
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
const nodes = useSession(s => s.nodes)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
&& item.results.some(r => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
@@ -280,36 +284,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>
)}
{pending.map(item => <PendingCard key={item.key} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />

View File

@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
@@ -146,7 +153,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
</div>
)
default:

View File

@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []

View File

@@ -52,7 +52,7 @@ export function ToolRow({
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
setExpanded(v => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()

View File

@@ -144,7 +144,7 @@ export interface ToolRowOwnerProps {
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails(): void
openDetails: () => void
}
/**
@@ -175,21 +175,21 @@ export interface ConversationInjected {
* Connect the selected Workspace and open its reusable/new blank session.
* When a blank session is already current, carry its draft to the target.
*/
selectWorkspace(workspaceId: WorkspaceId): Promise<void>
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
}
/** Business callbacks injected into the strict session content seat. */
export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
list: () => readonly ViewTab[]
subscribe: (fn: () => void) => () => void
version: () => number
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror(write: (text: string) => void): () => void
bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */
open(sessionId: SessionId): void
open: (sessionId: SessionId) => void
}
/**
@@ -219,7 +219,7 @@ export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop(): void
stop: () => void
}
/**
@@ -275,8 +275,8 @@ export type ConversationSessionSlotProps =
*/
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
loadOlder(): void
openDetails: (target: SelectionTarget) => void
loadOlder: () => void
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
@@ -290,7 +290,7 @@ export type ChatViewSlotProps =
*/
export interface DetailsInjected {
/** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void
closeDetails: () => void
}
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
@@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
onPick(workspaceId: WorkspaceId): void
onClose(): void
onPick: (workspaceId: WorkspaceId) => void
onClose: () => void
}

View File

@@ -78,12 +78,12 @@ export function ConversationRoot({
const inputBar = sessionId === undefined
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
})
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
})
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>

View File

@@ -41,8 +41,8 @@ export function ConversationSession({
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
const unmirror = bindDraftMirror(actions.setDraft)
return () => { unmirror() }
// Mount-only: later store writes come from the machine mirror.
// eslint-disable-next-line react-hooks/exhaustive-deps
// Mount-only (deps pinned to inputActions): later store writes come from
// the machine mirror, not this seed effect.
}, [inputActions])
if (blank && composerPhase === 'blank') return null

View File

@@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
: material === null
? <div className={css.empty}></div>
: (
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
</section>
)}
<>
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
{/* materialFor invariant: result===null ⇔ running (a settled
material always carries its result node). */}
{material.result === null
? <div className={css.empty}></div>
: (
<pre className={css.code} data-error={material.result.isError || undefined}>
{renderResult(material.result)}
</pre>
)}
<div className={css.sectionLabel}>Input</div>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
</section>
</>
)}
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
{/* materialFor invariant: result===null ⇔ running (a settled
material always carries its result node). */}
{material.result === null
? <div className={css.empty}></div>
: (
<pre className={css.code} data-error={material.result.isError || undefined}>
{renderResult(material.result)}
</pre>
)}
</section>
</>
)}
</div>
</div>
)

View File

@@ -31,7 +31,11 @@ export function InputBar({
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -75,6 +79,8 @@ export function InputBar({
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
@@ -92,7 +98,7 @@ export function InputBar({
// the browser stack cannot represent); never let the native stack run.
e.preventDefault()
if (machineBusy || locked) return
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
const redo = e.key === 'y' || e.shiftKey
if (redo) keyboard.redo()
else keyboard.undo()
return
@@ -134,6 +140,8 @@ export function InputBar({
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length)
}
@@ -145,10 +153,13 @@ export function InputBar({
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget
@@ -330,8 +341,8 @@ export function InputBar({
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={e => { onCopyOrCut(e, false) }}
onCut={e => { onCopyOrCut(e, true) }}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}

View File

@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
// Reflect.get is typed any; the probe only records property names.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy as Context)
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
@@ -73,14 +75,15 @@ async function bench() {
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped)
}
return scoped
}
type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>; props?: Record<string, unknown>
hooks?: Record<string, unknown>
props?: Record<string, unknown>
}
}
const providers: TestProvider[] = []
@@ -158,10 +161,12 @@ async function bench() {
const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as {
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
getSnapshot: () => { draft: string }
subscribe: (fn: () => void) => () => void
}
const actions = contribution.props!['inputActions'] as {
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
}
return { state, actions }
}
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
// no draft movement, plain re-open.
const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me')
resident.selectWorkspace('workspace-1' as never)
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(state.getSnapshot().draft).toBe('carry me')
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
resident.selectWorkspace('workspace-2' as never)
void resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')

View File

@@ -31,7 +31,7 @@ async function bench() {
},
current: undefined,
phase: 'ready',
} as SessionListState)
})
const sessionsFake = {
list: listStore,
binding: vi.fn(),
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
const b = await bench()
await b.fiber.await()
const entries = b.slots.entries('conversation.view')
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
expect(entries.map(e => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
@@ -117,7 +117,7 @@ describe('apply wiring', () => {
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {

View File

@@ -103,7 +103,7 @@ describe('MessageItem arms', () => {
it('context and unknown nodes render their JSON rows', () => {
const ctxView = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
const unknownView = render(

View File

@@ -59,7 +59,7 @@ function snapshotWith(
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */

View File

@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
render(<Counting {...props(source)} />)
const before = renders
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
act(() => set({ running: true }))
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
act(() => { set({ running: true }) })
expect(renders).toBe(before)
})
})
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
},
current: undefined,
phase: 'ready',
} as SessionListState)
})
}
const rowProps = (sessionId: SessionId, over?: {

View File

@@ -42,7 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
return info
},
maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
@@ -252,7 +256,7 @@ describe('registrant load-order seam', () => {
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} },
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
assistant(5, 'found'), toolResult(6, 'c'),
]
const items = deriveChatFlow(nodes)
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
const group = items[2]!
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
@@ -155,7 +156,7 @@ describe('ChatView', () => {
fireEvent.scroll(scroller)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
})
@@ -240,10 +241,10 @@ describe('ChatView', () => {
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.props.renderSlot = (((_key: string, _owner: object) => {
h.props.renderSlot = ((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
}) as unknown as ChatViewSlotProps['renderSlot'])
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -270,7 +271,7 @@ describe('ChatView', () => {
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
})
@@ -284,10 +285,10 @@ describe('ChatView', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
})
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
@@ -306,10 +307,10 @@ describe('ChatView', () => {
// Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
// A new trailing user bubble (own words) force-scrolls to the bottom.
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
expect(scroller.scrollTop).toBe(1600)
})
@@ -324,7 +325,7 @@ describe('ChatView', () => {
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down.
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(1000)
@@ -337,7 +338,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('加载更早'))
expect(h.loadOlder).toHaveBeenCalledTimes(1)
act(() => h.set({ loadingOlder: true }))
act(() => { h.set({ loadingOlder: true }) })
expect(view.getByText('加载中…')).toBeTruthy()
})

View File

@@ -22,7 +22,7 @@ afterEach(cleanup)
describe('tails', () => {
it('node-half apply is an intentional no-op', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
@@ -83,7 +83,7 @@ describe('tails', () => {
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
} as SessionListState)
})
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),

View File

@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
}
describe('render branch tails', () => {
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}

View File

@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
})),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
const { textarea, wiring } = bench()
fireEvent.change(textarea, { target: { value: 'typed' } })
expect(wiring.state.getSnapshot().draft).toBe('typed')
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
expect((textarea).value).toBe('typed')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
cleanup()
const live = bench({ running: true })
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
})
it('disabled locks the Access placeholder and attach control (running does not)', () => {

View File

@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
})),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
expect((textarea).readOnly).toBe(false)
// Free editing beyond the token: hint drops, claim holds.
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
expect(shell.snapshot.phase).toBe('claimed')
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
expect(sink).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
// Commit: draft cleared, notice surfaced, back to plain.
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
await vi.waitFor(() => { expect((textarea).value).toBe('') })
expect(view.getByText('完成')).toBeTruthy()
})
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(shell.snapshot.phase).toBe('submitting')
expect(shell.snapshot.claim).toBeDefined()
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
expect((textarea).readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
act(() => { rejectSubmit(new Error('执行失败')) })
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
expect((first.textarea).value).toBe('/goal ')
expect(first.view.getByText('执行失败')).toBeTruthy()
cleanup()
// Drift: typing during flight wins; no restore, plain, notice only.
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
act(() => { rejectSubmit(new Error('晚到失败')) })
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
expect((second.textarea).value).toBe('用户飞行中打的新稿')
expect(second.view.getByText('晚到失败')).toBeTruthy()
})
})
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
expect((textarea).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
const { textarea, sink } = bench({ running: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')

View File

@@ -12,7 +12,6 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
await ctx.plugin(SlashService).await()
const slash = ctx.get('slash') as SlashService
register?.(slash)
const actx = sessions.scope(sessionId)! as ClientContext
const actx = sessions.scope(sessionId)!
const controller = slash.sessionOf(actx)
const sink = vi.fn()
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
})),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
const textarea = view.container.querySelector('textarea')!
const type = (text: string): void => {
fireEvent.change(textarea, { target: { value: text } })
}
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
const execute = vi.fn(executeImpl ?? ((line: string) =>
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
const { source, executed } = commandSource(COMMANDS, execute)
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
const base = await scopedBench((slash) => { slash.registerSource(source) })
return { ...base, execute, executed }
}

View File

@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
/**
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
* `side` keys the hover-reveal CSS to the owning column.
*/
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false)
const origin = useRef(0)
@@ -86,7 +89,7 @@ export function AppFrame({
actions,
renderSlot,
}: AppFrameProps) {
const panels = useStore((s) => s)
const panels = useStore(s => s)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)

View File

@@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null
class ResizeObserverStub {
#cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
observe(): void { fireResize = () => { this.#cb([], this) } }
unobserve(): void {}
disconnect(): void { fireResize = null }
}
@@ -48,7 +48,7 @@ let frameWidth = 1920
/** Test-local selector hook over a framework-neutral store instance. */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
}
function mountFrame() {
@@ -118,7 +118,7 @@ beforeEach(() => {
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
window.innerWidth = frameWidth
Element.prototype.getBoundingClientRect = function () {
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
}
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
const captured = new WeakSet<Element>()
@@ -143,12 +143,12 @@ describe('AppFrame', () => {
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key)
const keys = slotCalls.map(c => c.key)
expect(keys).toContain('conversation')
expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
})
it('keeps the conversation slot mounted while no session is current', () => {
@@ -157,7 +157,7 @@ describe('AppFrame', () => {
sessionMode.current = false
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(slotCalls.map((c) => c.key)).toContain('conversation')
expect(slotCalls.map(c => c.key)).toContain('conversation')
})
it('renders both column occupants before baselines settle (no loading gate)', () => {
@@ -165,13 +165,13 @@ describe('AppFrame', () => {
// pending rendering — both occupants mount from first paint.
baselinesReady.current = false
const { slotCalls } = mountFrame()
expect(slotCalls.map((c) => c.key)).toContain('conversation')
expect(slotCalls.map((c) => c.key)).toContain('details')
expect(slotCalls.map(c => c.key)).toContain('conversation')
expect(slotCalls.map(c => c.key)).toContain('details')
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
@@ -211,7 +211,7 @@ describe('AppFrame', () => {
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
})

View File

@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
variant?: ButtonVariant
size?: 'md' | 'sm'
icon?: ReactNode
className?: string
className?: string | undefined
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) {
return (

View File

@@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
{items.map((entry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)
})}
)}
</div>
)
})}
</div>
)

View File

@@ -27,7 +27,8 @@ interface AnchorProps {
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/

View File

@@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
<path transform="translate(0.6689 1.073)" d="M11.4818 5.57813C11.4818 4.45301 11.4807 3.66237 11.4075 3.05908C11.3359 2.46953 11.2024 2.13852 10.9939 1.89441C10.9247 1.81341 10.8493 1.73801 10.7683 1.66882C10.5242 1.46033 10.1932 1.32686 9.60364 1.25525C9.00034 1.18198 8.20974 1.18091 7.0846 1.18091L5.57813 1.18091C4.45301 1.18091 3.66238 1.18198 3.05908 1.25525C2.46953 1.32686 2.13852 1.46033 1.89441 1.66882C1.81341 1.73801 1.73801 1.81341 1.66882 1.89441C1.46033 2.13852 1.32686 2.46953 1.25525 3.05908C1.18198 3.66238 1.18091 4.45301 1.18091 5.57813L1.18091 6.2771C1.18091 7.40218 1.18197 8.19288 1.25525 8.79614C1.32687 9.38553 1.46036 9.71674 1.66882 9.96082C1.73797 10.0417 1.81347 10.1173 1.89441 10.1864C2.13851 10.3948 2.46965 10.5275 3.05908 10.5991C3.66238 10.6724 4.45298 10.6735 5.57813 10.6735L7.0846 10.6735C8.20977 10.6735 9.00033 10.6724 9.60364 10.5991C10.1931 10.5275 10.5242 10.3948 10.7683 10.1864C10.8493 10.1173 10.9247 10.0417 10.9939 9.96082C11.2024 9.71674 11.3358 9.38553 11.4075 8.79614C11.4808 8.19288 11.4818 7.40218 11.4818 6.2771L11.4818 5.57813ZM12.6627 6.2771C12.6627 7.37222 12.6637 8.247 12.5798 8.93799C12.4942 9.64284 12.3133 10.2359 11.8928 10.7282C11.7834 10.8562 11.6637 10.9751 11.5356 11.0845C11.0434 11.5049 10.4511 11.6867 9.74634 11.7723C9.05525 11.8563 8.17999 11.8552 7.0846 11.8552L5.57813 11.8552C4.48273 11.8552 3.60747 11.8563 2.91638 11.7723C2.21157 11.6867 1.61933 11.5049 1.12708 11.0845C0.99901 10.9751 0.879281 10.8562 0.769898 10.7282C0.349454 10.2359 0.168506 9.64284 0.0828864 8.93799C-0.00101964 8.247 4.88512e-07 7.37222 6.47206e-07 6.2771L6.47206e-07 5.57813C6.47206e-07 4.48273 -0.00106163 3.60747 0.0828864 2.91638C0.168502 2.21168 0.349594 1.61928 0.769898 1.12708C0.879302 0.998981 0.998981 0.879302 1.12708 0.769898C1.61928 0.349594 2.21168 0.168502 2.91638 0.0828864C3.60747 -0.00106163 4.48273 6.47206e-07 5.57813 6.47206e-07L7.0846 6.47206e-07C8.17999 6.47206e-07 9.05525 -0.00106163 9.74634 0.0828864C10.451 0.168505 11.0434 0.349587 11.5356 0.769898C11.6637 0.879302 11.7834 0.998981 11.8928 1.12708C12.3131 1.61928 12.4942 2.21169 12.5798 2.91638C12.6638 3.60747 12.6627 4.48273 12.6627 5.57813L12.6627 6.2771Z" fill="currentColor"/>
<path transform="translate(0.6689 1.073)" d="M6.02607 5.50955L6.44306 5.9274L3.84284 8.52762L3.425 8.11063L3.00715 7.69278L4.77253 5.9274L3.00715 4.16202L3.84284 3.32633L6.02607 5.50955Z" fill="currentColor"/>
<path transform="translate(0.6689 1.073)" d="M9.23789 7.35397L9.23789 8.53488L6.96238 8.53488L6.96238 7.35397L9.23789 7.35397Z" fill="currentColor"/>
</svg>
</svg>
)
/** ic_ds_personalization_outline_16 (figma extract) */
export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
<path transform="translate(1.292 1.3)" d="M10.3232 9.18164C11.2868 9.18164 12.0985 9.82833 12.3506 10.7109L13.415 10.7109L13.415 11.8711L12.3496 11.8711C12.0971 12.7532 11.2864 13.3994 10.3232 13.3994C9.36031 13.3992 8.55012 12.7531 8.29785 11.8711L0 11.8711L0 10.7109L8.29688 10.7109C8.54876 9.82845 9.35988 9.18186 10.3232 9.18164ZM10.3232 10.3418C9.7999 10.3421 9.37534 10.7667 9.375 11.29C9.375 11.8137 9.79969 12.239 10.3232 12.2393C10.847 12.2393 11.2725 11.8138 11.2725 11.29C11.2721 10.7666 10.8468 10.3418 10.3232 10.3418ZM12.4326 11.291C12.4326 11.3549 12.4284 11.418 12.4229 11.4805C12.4287 11.4181 12.4326 11.355 12.4326 11.291ZM8.21484 11.2832C8.21484 11.2856 8.21484 11.2886 8.21484 11.291L8.21484 11.29C8.21484 11.2878 8.21484 11.2855 8.21484 11.2832ZM3.08301 4.59082C4.04605 4.59095 4.85696 5.23717 5.10938 6.11914L13.415 6.11914L13.415 7.2793L5.11035 7.2793C4.85833 8.16202 4.04648 8.80846 3.08301 8.80859C2.11972 8.80843 1.30963 8.16179 1.05762 7.2793L0 7.2793L0 6.11914L1.05762 6.11914C1.30994 5.23728 2.12006 4.59098 3.08301 4.59082ZM3.08301 5.75098C2.55962 5.75117 2.13512 6.17587 2.13477 6.69922C2.13477 7.22287 2.5594 7.64824 3.08301 7.64844C3.60665 7.64828 4.03223 7.2229 4.03223 6.69922C4.03187 6.17585 3.60643 5.75113 3.08301 5.75098ZM5.19238 6.69922C5.19238 6.763 5.18816 6.82633 5.18262 6.88867C5.18846 6.82629 5.19238 6.76313 5.19238 6.69922C5.19236 6.63495 5.18853 6.57152 5.18262 6.50879C5.18826 6.57154 5.19236 6.635 5.19238 6.69922ZM0.982422 6.52344C0.977382 6.58136 0.97463 6.63999 0.974609 6.69922C0.974609 6.75775 0.977496 6.81579 0.982422 6.87305C0.977758 6.81579 0.974609 6.75767 0.974609 6.69922C0.974628 6.64 0.977618 6.58142 0.982422 6.52344ZM10.3232 0C11.2869 0 12.0986 0.646596 12.3506 1.5293L13.415 1.5293L13.415 2.68945L12.3496 2.68945C12.363 2.64266 12.3754 2.59488 12.3857 2.54688C12.1838 3.50118 11.3376 4.21777 10.3232 4.21777C9.36037 4.21756 8.55018 3.57139 8.29785 2.68945L0 2.68945L0 1.5293L8.29688 1.5293C8.5487 0.646717 9.35981 0.00021854 10.3232 0ZM10.3232 1.16016C9.79984 1.16042 9.37524 1.58499 9.375 2.1084C9.375 2.63201 9.79969 3.05735 10.3232 3.05762C10.847 3.05762 11.2725 2.63217 11.2725 2.1084C11.2722 1.58483 10.8469 1.16016 10.3232 1.16016ZM12.4229 2.29883C12.4287 2.23641 12.4326 2.17331 12.4326 2.10938C12.4326 2.17327 12.4284 2.23638 12.4229 2.29883ZM8.21484 2.10938L8.21484 2.1084L8.21484 2.10938ZM8.22266 1.93359C8.21785 1.98897 8.21506 2.04499 8.21484 2.10156C8.21503 2.04501 8.2181 1.98902 8.22266 1.93359ZM8.22266 11.1162C8.2179 11.1713 8.21507 11.227 8.21484 11.2832C8.21504 11.227 8.21814 11.1713 8.22266 11.1162Z" fill="currentColor"/>
</svg>
</svg>
)
/** ic_ds_project_add_outline_16 (figma extract) */
@@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
<path transform="translate(9.52 2.52)" d="M3.55246 0L3.55246 2.44252L6 2.44252L6 3.55748L3.55246 3.55748L3.55246 6L2.43834 6L2.43834 3.55748L0 3.55748L0 2.44252L2.43834 2.44252L2.43834 0L3.55246 0Z" fill="currentColor"/>
<path transform="translate(0.3496 2.35)" d="M4.76367 0C5.36861 1.80598e-05 5.93113 0.310294 6.25488 0.821289L6.78027 1.64941C6.79685 1.67558 6.81791 1.69775 6.83887 1.71973C6.72186 2.15521 6.65702 2.61192 6.65137 3.08301C6.25601 2.96045 5.90909 2.70478 5.68164 2.3457L5.15723 1.5166C5.07183 1.38189 4.92318 1.3008 4.76367 1.30078L2.32422 1.30078C1.7589 1.30078 1.30078 1.7589 1.30078 2.32422L1.30078 10.1338C1.30078 10.6991 1.7589 11.1572 2.32422 11.1572L11.9766 11.1572C12.5419 11.1572 13 10.6991 13 10.1338L13 8.58398C13.4545 8.5135 13.8903 8.38748 14.3008 8.21289L14.3008 10.1338C14.3008 11.4171 13.2598 12.458 11.9766 12.458L2.32422 12.458C1.04093 12.458 0 11.4171 0 10.1338L0 2.32422C0 1.04093 1.04093 0 2.32422 0L4.76367 0Z" fill="currentColor"/>
</svg>
</svg>
)
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
@@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
</svg>
</svg>
)
/** folder_close_16 (figma extract) */
export const IconFolderClose16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
<path transform="translate(1.5 2.429)" d="M5.05582 0.518756L4.50669 0.86654L5.05582 0.518756ZM13 9.4837L13.65 9.4837L13.65 3.53962L13 3.53962L12.35 3.53962L12.35 9.4837L13 9.4837ZM11.3264 1.86603L11.3264 1.21603L6.52313 1.21603L6.52313 1.86603L6.52313 2.51603L11.3264 2.51603L11.3264 1.86603ZM5.58054 1.34727L6.12968 0.999489L5.60495 0.170972L5.05582 0.518756L4.50669 0.86654L5.03141 1.69506L5.58054 1.34727ZM4.11323 1.23058e-13L4.11323 -0.65L1.67359 -0.65L1.67359 5.00699e-14L1.67359 0.65L4.11323 0.65L4.11323 1.23058e-13ZM0 1.67359L-0.65 1.67359L-0.65 9.4837L0 9.4837L0.65 9.4837L0.65 1.67359L0 1.67359ZM11.3264 11.1573L11.3264 10.5073L1.67359 10.5073L1.67359 11.1573L1.67359 11.8073L11.3264 11.8073L11.3264 11.1573ZM0 9.4837L-0.65 9.4837C-0.65 10.767 0.390308 11.8073 1.67359 11.8073L1.67359 11.1573L1.67359 10.5073C1.10828 10.5073 0.65 10.049 0.65 9.4837L0 9.4837ZM1.67359 5.00699e-14L1.67359 -0.65C0.390307 -0.65 -0.65 0.390309 -0.65 1.67359L0 1.67359L0.65 1.67359C0.65 1.10828 1.10828 0.65 1.67359 0.65L1.67359 5.00699e-14ZM5.05582 0.518756L5.60495 0.170972C5.28121 -0.340193 4.71829 -0.65 4.11323 -0.65L4.11323 1.23058e-13L4.11323 0.65C4.27282 0.65 4.4213 0.731715 4.50669 0.86654L5.05582 0.518756ZM6.52313 1.86603L6.52313 1.21603C6.36354 1.21603 6.21507 1.13431 6.12968 0.999489L5.58054 1.34727L5.03141 1.69506C5.35515 2.20622 5.91808 2.51603 6.52313 2.51603L6.52313 1.86603ZM13 3.53962L13.65 3.53962C13.65 2.25634 12.6097 1.21603 11.3264 1.21603L11.3264 1.86603L11.3264 2.51603C11.8917 2.51603 12.35 2.97431 12.35 3.53962L13 3.53962ZM13 9.4837L12.35 9.4837C12.35 10.049 11.8917 10.5073 11.3264 10.5073L11.3264 11.1573L11.3264 11.8073C12.6097 11.8073 13.65 10.767 13.65 9.4837L13 9.4837Z" fill="currentColor"/>
</svg>
</svg>
)
/** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */

View File

@@ -20,6 +20,9 @@ export interface CodeBlockProps {
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
@@ -64,20 +71,20 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => setCopied(false), 1000)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, trimmed])
const body = html === undefined
? (
<pre className={css.plain}><code>{trimmed}</code></pre>
)
<pre className={css.plain}><code>{trimmed}</code></pre>
)
: (
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
// shiki's output is a static span tree it generated from `code` (no user
// HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>

View File

@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
if (!open) return ''
let s: string
try {
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
s = JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
s = String(payload)
@@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
}, [open, payload])
return (
<div className={css.root}>
<button type="button" className={css.toggle} onClick={() => setOpen((v) => !v)}>
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
{open ? '▾' : '▸'} {label}
</button>
{open && <pre className={css.body}>{body}</pre>}

View File

@@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url)
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the default <code> path (the :not(pre)
@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
// plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once.
pre: ({ children }) => {
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)

View File

@@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
wrapper.getBoundingClientRect = () => ({
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
} as DOMRect)
})
}
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {

View File

@@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => {
expect(iconNames.length).toBe(55)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
const Icon = icons[name]!
const { container } = render(<Icon />)
const svg = container.querySelector('svg')

View File

@@ -153,7 +153,7 @@ describe('JsonBlock', () => {
it('truncates beyond the size cap with a suffix note', () => {
const big = 'x'.repeat(30_000)
const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
const body = container.querySelector('pre')!.textContent!
const body = container.querySelector('pre')!.textContent
expect(body.length).toBeLessThan(30_000)
expect(body).toContain('截断')
})

View File

@@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('StateDot', () => {
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => {
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => {
const { container } = render(<StateDot state={state} />)
const dot = container.firstElementChild as HTMLElement
expect(dot.dataset['state']).toBe(state)

View File

@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const question = questions[index]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
@@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
: item)
setDrafts(nextDrafts)
setError(null)

View File

@@ -50,7 +50,7 @@ const QUESTIONS = [
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
const carrier = new PendingWait(
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
return { carrier, respond }
}
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
]))
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
it('skips individual questions without discarding earlier answers', () => {
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()

View File

@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
mount()
expect(screen.getByText('Permission')).toBeTruthy()
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
expect(selector.disabled).toBe(true)
})

View File

@@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
// Local selection; entries can unmount underneath it, so the render-time
// projection falls back to the first row when the id is gone.
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
const titleId = useId()
useEffect(() => {
@@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
<nav className={css.nav}>
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
<div className={css.navList}>
{rows.map((row) => (
{rows.map(row => (
<button
key={row.id}
type="button"

View File

@@ -26,7 +26,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
startSession={startSession} toggleSidebar={toggleSidebar}
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
if (key === 'sidebar.settings') {
settingsOwner = owner as SidebarSettingsOwnerProps
settingsOwner = owner
return <div data-testid="settings-seat" data-wide={owner.wide} />
}
regionOwner = owner as SidebarSectionOwnerProps

View File

@@ -34,5 +34,5 @@ export interface MenuViewInjected {
* @param source - source (group) name.
* @param index - candidate index within the group.
*/
onPick(source: string, index: number): void
onPick: (source: string, index: number) => void
}

View File

@@ -41,7 +41,7 @@ function createPanelStore() {
})
}
const chatStore = () => defineStore({
const _chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: {
select: (d, t: { id: string }) => { d.selection = t },
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
clearDraft: (d) => { d.draft = '' },
},
})
type ChatHandle = ReturnType<typeof chatStore>
type ChatHandle = ReturnType<typeof _chatStore>
type FrameProps =
& PropsRuntime<'chain.frame'>
@@ -115,7 +115,7 @@ describe('terminal-design type chain', () => {
// member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore((s) => s.draft)
const draft: string = cp.useStore(s => s.draft)
cp.actions.select({ id: 'm1' })
void draft
@@ -127,7 +127,7 @@ describe('terminal-design type chain', () => {
// chain position.
core.register({
name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
priority: 1,
}, Takeover)
@@ -135,7 +135,7 @@ describe('terminal-design type chain', () => {
// checks through parameter contravariance.
core.register({
name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
}, WideTakeover)
// renderSlotChain share: chain keys dispatch with the fallback bag;
@@ -179,7 +179,7 @@ describe('terminal-design type chain', () => {
name: 'chain.side',
// @ts-expect-error root-scope inject has no sessionId parameter
inject: (sessionId: string) => ({ x: sessionId }),
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
}, (_p => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
// keyed registration without key.
// @ts-expect-error keyed registration requires options.key
@@ -195,14 +195,14 @@ describe('terminal-design type chain', () => {
// @ts-expect-error component matched prop drifts from the select return
core.register({
name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q') ?? null,
}, NarrowTakeover)
// select must return M | null, not undefined (find() must be coalesced).
// @ts-expect-error select may not return undefined
core.register({
name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q'),
}, Takeover)
// Chain keys are not renderSlot-dispatchable (and vice versa).
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
chainSlots.renderSlotChain('chain.conv', {})
// @ts-expect-error a children set without chain keys provides no renderSlotChain
fp.renderSlotChain
type _NoChainSeat = typeof fp.renderSlotChain
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)

View File

@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'Sub',
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
user: css.tagUser!,
message: css.tagMessage!,
tool: css.tagTool!,
subtool: css.tagSubtool!,
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
user: css.tagUser,
message: css.tagMessage,
tool: css.tagTool,
subtool: css.tagSubtool,
}
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
@@ -84,7 +84,7 @@ export function TrajectoryCell({
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
<span className={css.index}>#{index}</span>
<span className={css.tagSlot}>
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
</span>
<span className={css.text}>{text}</span>
<span className={css.trailing}>

View File

@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
const nodes = useSession((s) => s.nodes)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>

View File

@@ -20,7 +20,7 @@ export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
<div className={css.inner}>
<span className={css.title}>Turn {turn}</span>
<div className={css.columns} aria-hidden="true">
{COLUMN_LABELS.map((label) => (
{COLUMN_LABELS.map(label => (
<span key={label} className={css.column}>{label}</span>
))}
</div>

View File

@@ -9,10 +9,10 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession((s) => s.nodes)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches)
const nodes = useSession(s => s.nodes)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches],
@@ -22,15 +22,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
}
return (
<div className={css.root}>
{turns.map((turn) => (
{turns.map(turn => (
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
{turn.groups.flatMap((group) => [
{turn.groups.flatMap(group => [
<TrajectoryGroupHeader
key={`${group.title}-h`}
title={group.title}
{...(group.description !== undefined ? { description: group.description } : {})}
/>,
...group.cells.map((cell) => (
...group.cells.map(cell => (
<TrajectoryCell key={cell.index} {...cell} />
)),
])}

View File

@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession((s) => s.nodes)
const codeDispatches = useSession((s) => s.codeDispatches)
const nodes = useSession(s => s.nodes)
const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
@@ -50,7 +50,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map((lane) => (
{(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span

View File

@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))

View File

@@ -73,13 +73,13 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
expect(kinds).toEqual(['user', 'message', 'tool'])
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
input: 10, output: 20, think: 5, timeSeconds: 5,
})
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool?.timeSeconds).toBe(1.3)
})
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [] as unknown as ConversationSnapshot['nodes'],
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
}],
})
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
})
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
})
it('builds a wall-span step description with a tool histogram', () => {
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage on the fallback Message row when assistant has no text block', () => {
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
})
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
.flatMap(g => g.cells)
.find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
@@ -234,10 +234,10 @@ describe('run_code sub-dispatch cells', () => {
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap((g) => g.cells)
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
})
@@ -249,7 +249,7 @@ describe('run_code sub-dispatch cells', () => {
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
})

View File

@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
version: () => slots.getVersion('conversation.view'),
}}
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
})
})
@@ -173,7 +173,7 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench()
mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -198,7 +198,7 @@ describe('tab switching in ConversationRoot', () => {
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
const b = await bench()
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
mount(b.slots, [])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -221,12 +221,12 @@ describe('span derivation', () => {
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
@@ -234,7 +234,7 @@ describe('span derivation', () => {
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
})
@@ -295,7 +295,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)

View File

@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
/**
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
@@ -32,7 +35,7 @@ const GROUP_BY_ITEMS = [
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
@@ -61,7 +64,7 @@ function GroupByMenu({ groupBy, onPick }: {
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
onClick={() => { setOpen(v => !v) }}
>
<IconPersonalizationOutline16 />
</button>
@@ -96,7 +99,7 @@ function SessionTree({
useSessions, startSession, open, workspaces, query,
onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions((s) => s)
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
@@ -108,7 +111,7 @@ function SessionTree({
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
@@ -129,22 +132,22 @@ function SessionTree({
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
actions={group.workspaceId === undefined
? undefined
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
@@ -189,7 +192,7 @@ function SessionTree({
currentId={current}
now={now}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
drag={dragProps}
/>
)
@@ -204,7 +207,7 @@ function SessionTree({
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
const list = useSessions((s) => s)
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
return (
@@ -292,7 +295,7 @@ export function WorkspaceBrowser({
setRenameError(null)
}
const confirmRename = () => {
if (renameBlocked || renameTarget === null) return
if (renameBlocked) return
setRenaming(true)
setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
@@ -419,24 +422,24 @@ export function WorkspaceBrowser({
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} query={query} />
: (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
<Modal
@@ -481,7 +484,7 @@ export function WorkspaceBrowser({
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
className={css.deleteAction}
disabled={deleting}
onClick={confirmDelete}
>

View File

@@ -71,7 +71,7 @@ export function WorkspaceCreateFlow({
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId as string,
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
@@ -179,10 +179,10 @@ export function WorkspaceCreateFlow({
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>

View File

@@ -59,9 +59,9 @@ export type WorkspaceBrowserProps =
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory(): Promise<string | null>
pickDirectory: () => Promise<string | null>
}
/**

View File

@@ -192,37 +192,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
onDragStart={drag === undefined
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
onDragEnd={drag?.end}
onDragOver={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
onDrop={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>

View File

@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
row.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
})
}
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {

View File

@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
@@ -297,7 +299,7 @@ describe('WorkspaceBrowser', () => {
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
three.getBoundingClientRect = () => ({
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
// Drop on the top half of "three": insert one before three.
@@ -310,7 +312,7 @@ describe('WorkspaceBrowser', () => {
fireEvent.dragStart(one, { dataTransfer })
one.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
})
fireDrag(one, 'dragOver', 105)
fireDrag(one, 'drop', 105)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
@@ -336,7 +338,7 @@ describe('WorkspaceBrowser', () => {
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
fireDrag(two, 'drop', 155)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
})
@@ -353,7 +355,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireEvent.dragEnd(one)
@@ -382,7 +384,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'drop', 180)
@@ -404,13 +406,13 @@ describe('WorkspaceBrowser', () => {
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
@@ -473,13 +475,13 @@ describe('WorkspaceBrowser', () => {
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))

View File

@@ -16,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
}
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready',
}
@@ -131,8 +133,8 @@ describe('WorkspacePicker', () => {
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
@@ -159,7 +161,7 @@ describe('WorkspacePicker', () => {
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})

View File

@@ -200,7 +200,8 @@ function standardKit(
scope: SlotScope,
info: SessionMaybeProvideInfo | undefined,
): {
kit: InjectedProps; actions: object | undefined
kit: InjectedProps
actions: object | undefined
} {
const kit: InjectedProps = {
useSessions: observableHook(host.sessions.list),
@@ -232,13 +233,13 @@ function standardKit(
kit['renderSlot'] = boundRenderSlot(host, entry)
// renderSlotChain rides the same declaration source: only entries whose
// children include a chain-kind slot receive the chain dispatch seat.
if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) {
if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
}
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider
}
}
@@ -253,7 +254,9 @@ function standardKit(
* composition point, one per scope branch).
*/
function SessionEntry({ entry, ownerProps, info }: {
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
entry: StoredEntry
ownerProps: object
info: SessionProvideInfo
}) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
}
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
slotKey: string; entry: StoredEntry; ownerProps: object
slotKey: string
entry: StoredEntry
ownerProps: object
}) {
const info = useSessionMaybeProvideInfo()
if (info.sessionId === undefined) return null
@@ -292,12 +297,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
}
function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
slotKey: string
ownerProps: object
opts?: (RenderOpts & ChainRenderOpts) | undefined
}) {
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
useSyncExternalStore(
(fn) => host.subscribe(slotKey, fn),
fn => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
const sessionInfo = useSessionMaybeProvideInfo()
@@ -321,12 +328,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
spec.scope === 'session'
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
: (
<SlotErrorBoundary slotKey={slotKey} key={key}>
{spec.scope === 'session-maybe'
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
: <RootEntry entry={entry} ownerProps={owner} />}
</SlotErrorBoundary>
)
<SlotErrorBoundary slotKey={slotKey} key={key}>
{spec.scope === 'session-maybe'
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
: <RootEntry entry={entry} ownerProps={owner} />}
</SlotErrorBoundary>
)
)
if (spec.kind === 'single') {
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
return guarded(entry)
}
if (spec.kind === 'keyed') {
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
const entry = entries.find(e => e.options.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
@@ -388,13 +395,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
return elected ?? <>{opts?.fallback ?? null}</>
}
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({
const withListOptions = entries.map(entry => ({
entry,
id: entry.options?.id,
order: entry.options?.order ?? 0,
id: entry.options.id,
order: entry.options.order ?? 0,
}))
let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
if (list.length === 0) return <>{opts?.fallback ?? null}</>
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
}
@@ -403,7 +410,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost()
useSyncExternalStore(
(fn) => host.subscribe('root', fn),
fn => host.subscribe('root', fn),
() => host.getVersion('root'),
)
const entry = host.entriesOf('root')[0]

View File

@@ -73,11 +73,14 @@ const absentSource: HostObservable<undefined> = {
/** Bind a source that disappears with the current session to an optional selector hook. */
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
if (source !== undefined) return observableHook(source)
return useAbsentSnapshot as MaybeSnapshotSelectorHook<T>
return useAbsentSnapshot
}
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
return observableHook(absentSource)(() => undefined)
// The uSES subscription must still run (hook-order stability); the absent
// source always snapshots undefined, returned explicitly.
observableHook(absentSource)(() => undefined)
return undefined
}
/**
@@ -87,7 +90,7 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const id = observableHook(host.sessions.current)((s) => s)
const id = observableHook(host.sessions.current)(s => s)
return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
{children}
@@ -112,7 +115,7 @@ export interface SessionProviderProps {
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)((s) => s)
const id = observableHook(host.sessions.current)(s => s)
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
return (

View File

@@ -8,7 +8,7 @@ import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from
// Keep equality local: this suite asserts the eq parameter contract without
// adding a reverse dependency from web-react to runtime.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.is(a[k], b[k]))
interface Snap { a: number; b: number }
@@ -51,7 +51,7 @@ describe('bindSnapshotSelector', () => {
const { source, set } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as number | undefined }
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(probe.value).toBe(1)
const before = probe.renders
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
@@ -65,7 +65,7 @@ describe('bindSnapshotSelector', () => {
const { source, set } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as { a: number } | undefined }
render(<Harness useSelector={useSelector} sel={(s) => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
render(<Harness useSelector={useSelector} sel={s => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
const before = probe.renders
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
expect(probe.renders).toBe(before)
@@ -78,11 +78,11 @@ describe('bindSnapshotSelector', () => {
const { source, set, stats } = makeSource({ a: 1, b: 10 })
const useSelector = bindSnapshotSelector(source)
const probe = { renders: 0, value: undefined as number | undefined }
const { rerender } = render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
const { rerender } = render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
const after = stats.subscribeCalls
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
act(() => { set({ a: 2, b: 10 }) })
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(stats.subscribeCalls).toBe(after)
})
@@ -92,7 +92,7 @@ describe('bindSnapshotSelector', () => {
const probe = { renders: 0, value: undefined as number | undefined }
const view = render(
<StrictMode>
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />
<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
</StrictMode>,
)
expect(probe.value).toBe(1)
@@ -112,7 +112,7 @@ describe('bindSnapshotSelector', () => {
}
const useSelector = bindSnapshotSelector(new MethodSource())
const probe = { renders: 0, value: undefined as number | undefined }
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
expect(probe.value).toBe(7)
})

View File

@@ -28,10 +28,10 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
function hostOver(core: SlotCore): SlotRendererHost {
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: (key) => core.getVersion(key),
entriesOf: (key) => core.entries(key),
specOf: (key) => core.specDynamic(key),
isLive: (entry) => core.isLive(entry),
getVersion: key => core.getVersion(key),
entriesOf: key => core.entries(key),
specOf: key => core.specDynamic(key),
isLive: entry => core.isLive(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
@@ -61,7 +61,7 @@ function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot'])
describe('createSlotRenderer over the real SlotCore', () => {
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
const core = new SlotCore()
const { view } = mountFrame(core, (renderSlot) =>
const { view } = mountFrame(core, renderSlot =>
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
@@ -78,7 +78,7 @@ describe('createSlotRenderer over the real SlotCore', () => {
const core = new SlotCore()
const notified = vi.fn()
core.subscribe('spec.list', notified)
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {}))
const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
await act(async () => {
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)

View File

@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
* but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which web-react tests must not import (dependency direction).
*/
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
function miniStore<T extends object>(
init: () => T,
mutators: Record<string, (state: T, ...params: never[]) => T>,
): StoreHandle<T, ActionsDecl<T>> {
return {
spec: { init, actions: {} },
create: () => {
@@ -96,10 +99,10 @@ function makeHost() {
subs.set(key, set)
return () => { set.delete(fn) }
},
getVersion: (key) => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [],
specOf: (key) => specs.get(key),
isLive: (entry) => live.has(entry),
getVersion: key => versions.get(key) ?? 0,
entriesOf: key => entries.get(key) ?? [],
specOf: key => specs.get(key),
isLive: entry => live.has(entry),
storeOf: (entry, scopeKey) => {
if (entry.store === undefined) return undefined
let perScope = storeCache.get(entry)
@@ -121,8 +124,8 @@ function makeHost() {
sessions: {
list,
current,
provideInfo: (id) => infos.get(id),
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: {}, props: {} },
},
workspaces: { list: workspaces },
@@ -145,7 +148,7 @@ function makeHost() {
live.add(entry)
bump(key)
return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
live.delete(entry)
bump(key)
}
@@ -187,7 +190,7 @@ const chainEntryOf = (partial: {
priority?: number
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
component: partial.component,
select: partial.select as StoredEntry['select'],
select: partial.select,
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
})
@@ -230,7 +233,7 @@ describe('child outlets and the renderSlot binding', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', {}, { fallback: <i>none</i> }))
renderSlot => renderSlot('k.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
@@ -242,7 +245,7 @@ describe('child outlets and the renderSlot binding', () => {
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
const h = makeHost()
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
renderSlot => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
// Declared by children (authorization) but absent from the ledger (specOf
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
expect(view.container.querySelector('main')!.textContent).toBe('')
@@ -256,7 +259,7 @@ describe('child outlets and the renderSlot binding', () => {
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
const { view } = mountRoot(h, children, (renderSlot) => <>
const { view } = mountRoot(h, children, renderSlot => <>
<main>{renderSlot('k.list', {})}</main>
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
@@ -291,7 +294,7 @@ describe('child outlets and the renderSlot binding', () => {
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => renderSlot('k.list', {}))
renderSlot => renderSlot('k.list', {}))
spy.mockRestore()
expect(view.container.textContent).toBe('alive')
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
@@ -309,10 +312,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
}))
h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }),
select: owner => ({ label: `hit:${(owner as { tag: string }).tag}` }),
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
// The declining entry never mounts: the routing decision is select-layer only.
expect(view.container.textContent).toBe('hit:T')
expect(declinerBody).not.toHaveBeenCalled()
@@ -327,10 +330,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
}))
h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
select: (owner) => (owner as { pick?: string }).pick ?? null,
select: owner => (owner as { pick?: string }).pick ?? null,
}))
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
</>)
@@ -347,16 +350,16 @@ describe('chain outlets and the renderSlotChain binding', () => {
h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({
component: () => { throw new Error('entry A boom') },
select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null,
select: owner => (owner as { pick?: string }).pick === 'A' ? {} : null,
}))
h.add('k.chain', chainEntryOf({
component: () => <b>B-ok</b>,
select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null,
select: owner => (owner as { pick?: string }).pick === 'B' ? {} : null,
}))
let pick = 'A'
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { pick }))
renderSlotChain => renderSlotChain('k.chain', { pick }))
spy.mockRestore()
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
@@ -372,9 +375,9 @@ describe('chain outlets and the renderSlotChain binding', () => {
h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
select: (owner) => (owner as { pick?: string }).pick ?? null,
select: owner => (owner as { pick?: string }).pick ?? null,
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
</>)
@@ -387,7 +390,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
act(() => {
@@ -422,7 +425,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
priority: 1,
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}))
renderSlotChain => renderSlotChain('k.chain', {}))
expect(view.container.textContent).toBe('early')
})
@@ -490,13 +493,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({
component: () => <b>TAKEOVER</b>,
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
select: owner => (owner as { take?: boolean }).take ? {} : null,
}))
const mounted = vi.fn()
const Probe = fallbackProbe(mounted)
let take = false
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
@@ -525,13 +528,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({
component: () => <b>TAKEOVER</b>,
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
select: owner => (owner as { take?: boolean }).take ? {} : null,
}))
const mounted = vi.fn()
const Probe = fallbackProbe(mounted)
let take = false
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
@@ -561,7 +564,7 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
priority: 2,
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
expect(view.container.textContent).toContain('ELECTED')
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
spy.mockRestore()
@@ -578,9 +581,9 @@ describe('standard-kit synthesis', () => {
h.declare('k.single', SINGLE_ROOT)
h.add('k.single', {
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useSessions((s) => s.ids.length)}</b>,
<b>{useSessions(s => s.ids.length)}</b>,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { h.list.set({ ids: ['a', 'b'] }) })
expect(view.container.textContent).toBe('2')
@@ -591,9 +594,9 @@ describe('standard-kit synthesis', () => {
h.declare('k.single', SINGLE_ROOT)
h.add('k.single', {
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useWorkspaces((s) => s.ids.length)}</b>,
<b>{useWorkspaces(s => s.ids.length)}</b>,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { h.workspaces.set({ ids: ['w1'] }) })
expect(view.container.textContent).toBe('1')
@@ -606,11 +609,11 @@ describe('standard-kit synthesis', () => {
const seen: AnyProps[] = []
h.add('k.session', {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
seen.push({ ...props, read: props.useSession!(s => s.sid) })
return null
},
})
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
</SessionProvider>
@@ -669,14 +672,14 @@ describe('standard-kit synthesis', () => {
h.declare('k.session', SINGLE_SESSION)
h.add('k.session', { component: () => <b>x</b> })
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
(renderSlot) => renderSlot('k.session', {}))
renderSlot => renderSlot('k.session', {}))
expect(view.container.querySelector('b')).toBeNull()
})
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
let bump = () => {}
h.add('k.single', {
component: ({ useStore, actions }: {
@@ -684,11 +687,11 @@ describe('standard-kit synthesis', () => {
actions: { inc: () => void }
}) => {
bump = actions.inc
return <b>{useStore((s) => s.n)}</b>
return <b>{useStore(s => s.n)}</b>
},
store: handle,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { bump() })
expect(view.container.textContent).toBe('1')
@@ -707,11 +710,11 @@ describe('standard-kit synthesis', () => {
actions: { setDraft: (text: string) => void }
}) => {
setDraft = actions.setDraft
return <b>{useStore((s) => s.draft) || '(blank)'}</b>
return <b>{useStore(s => s.draft) || '(blank)'}</b>
},
store: handle,
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
@@ -730,7 +733,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT)
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('FROM-INJECT')
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
expect(inject).toHaveBeenCalledTimes(1)
@@ -745,9 +748,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
h.add('k.session', {
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
inject: inject as unknown as StoredEntry['inject'],
inject: inject,
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
@@ -767,22 +770,22 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT)
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
const seenRoot: AnyProps[] = []
const seenSession: AnyProps[] = []
h.add('k.single', {
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
inject: rootInject as unknown as StoredEntry['inject'],
inject: rootInject,
store: handle,
})
h.add('k.session', {
component: (props: object) => { seenSession.push(props as AnyProps); return null },
inject: sessionInject as unknown as StoredEntry['inject'],
inject: sessionInject,
store: handle,
})
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <>
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
{renderSlot('k.single', {})}
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
</>)
@@ -807,7 +810,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => <main>{renderSlot('k.list', {})}</main>)
renderSlot => <main>{renderSlot('k.list', {})}</main>)
spy.mockRestore()
// The failing entry blacks out alone; the sibling and the tree above survive.
expect(view.container.querySelector('main')).not.toBeNull()
@@ -824,7 +827,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
})
mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
renderSlot => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
const props = seen.at(-1)!
expect(typeof props['useSessions']).toBe('function') // kit always present
expect(typeof props['useWorkspaces']).toBe('function')

View File

@@ -43,15 +43,15 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
const host: SlotRendererHost = {
subscribe: () => () => {},
getVersion: () => 0,
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
provideInfo: (id) => infos.get(id),
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
},
workspaces: { list: observable<unknown>({ items: [] }) },
@@ -78,7 +78,7 @@ describe('SessionProvider', () => {
const h = makeHost({
root: () => (
<SessionProvider empty={() => <span>empty</span>}>
{(id) => <div data-testid="body">{id}</div>}
{id => <div data-testid="body">{id}</div>}
</SessionProvider>
),
})
@@ -93,7 +93,7 @@ describe('SessionProvider', () => {
it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('')
@@ -110,7 +110,7 @@ describe('SessionProvider', () => {
return <div>{id}</div>
}
const h = makeHost({
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
})
h.addSession('s1')
h.addSession('s2')
@@ -127,7 +127,7 @@ describe('SessionProvider', () => {
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = []
const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
h.addSession('s1')
h.addSession('s2')
@@ -135,7 +135,7 @@ describe('SessionProvider', () => {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
// The bound hook reads the cell's bare source — asserting through it
// proves the machinery wired THIS session's source, not another's.
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
return null
},
options: {},
@@ -152,7 +152,7 @@ describe('SessionProvider', () => {
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
<SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
)).toThrow(/outside the installed renderer tree/)
spy.mockRestore()
})

View File

@@ -32,10 +32,10 @@ function makeHost() {
subs.set(key, set)
return () => { set.delete(fn) }
},
getVersion: (key) => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [],
getVersion: key => versions.get(key) ?? 0,
entriesOf: key => entries.get(key) ?? [],
specOf: () => ({ kind: 'single', scope: 'root' }),
isLive: (entry) => live.has(entry),
isLive: entry => live.has(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
@@ -54,7 +54,7 @@ function makeHost() {
live.add(entry)
bump(key)
return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
live.delete(entry)
bump(key)
}

View File

@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
function deferred<T>() {
let resolve!: (v: T) => void
function deferred() {
let resolve!: () => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
describe('useInvoke', () => {
it('tracks pending across the action lifecycle', async () => {
const d = deferred<void>()
const d = deferred()
const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />)
expect(probe.pending).toBe(false)
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
})
it('keeps pending true until the last concurrent call settles', async () => {
const d1 = deferred<void>()
const d2 = deferred<void>()
const d1 = deferred()
const d2 = deferred()
const queue = [d1, d2]
const probe = newProbe()
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
it('resets pending and logs when the action rejects', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const d = deferred<void>()
const d = deferred()
const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />)
act(() => { probe.invoke() })

View File

@@ -42,18 +42,18 @@ export function AppRoot(props: AppRootProps) {
<div className={css.wordmark}>HARNESS</div>
{!loud
? (
<>
<div className={css.spinner} />
<div className={css.hint}>Loading plugins</div>
</>
)
<>
<div className={css.spinner} />
<div className={css.hint}>Loading plugins</div>
</>
)
: (
<div className={css.failed}>
<div className={css.failedTitle}>Failed to load plugins</div>
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
{error !== undefined && <div className={css.failedItem}>{error}</div>}
</div>
)}
<div className={css.failed}>
<div className={css.failedTitle}>Failed to load plugins</div>
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
{error !== undefined && <div className={css.failedItem}>{error}</div>}
</div>
)}
</div>
</div>
)

View File

@@ -7,7 +7,6 @@
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
@@ -26,7 +25,7 @@ export interface AssemblyDeps {
*/
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps
const sessions = ctx.get('sessions') as SessionsService | undefined
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
const useSessions = bindSnapshotSelector(sessions.list)
const SessionDocumentTitle = (): ReactNode => {

View File

@@ -150,8 +150,8 @@ export class AppWebEntry {
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
private async prefetchImmediateTier(): Promise<void> {
await Promise.all(this.manifest.plugins
.filter((row) => row.immediately)
.map((row) => this.modules.prefetch(row.id).catch(() => {
.filter(row => row.immediately)
.map(row => this.modules.prefetch(row.id).catch(() => {
// Import refetches and reports this loudly per entry; swallowing
// here keeps one failing prefetch from masking the others.
})))
@@ -186,7 +186,7 @@ export class AppWebEntry {
// its wrapper apply reads the kernel slot and provides ctx.modules (the
// provide lives on the plugin face; see MODULES_ID for why the row loop
// must then skip it).
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
@@ -225,7 +225,7 @@ export class AppWebEntry {
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)

View File

@@ -46,13 +46,13 @@ export type LoaderStatus = Record<string, LoaderEntryState>
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
export interface KernelSignal<T> {
/** Current value (stable reference between changes). */
getSnapshot(): T
getSnapshot: () => T
/**
* Subscribe to changes.
* @param fn - change listener.
* @returns the unsubscribe disposer.
*/
subscribe(fn: () => void): () => void
subscribe: (fn: () => void) => () => void
}
/** Writable one-value signal (settled flag, boot failure report). */
@@ -61,7 +61,7 @@ export interface KernelValueSignal<T> extends KernelSignal<T> {
* Publish a new value and notify subscribers.
* @param next - the new value.
*/
set(next: T): void
set: (next: T) => void
}
/**
@@ -90,7 +90,7 @@ export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
* @param id - entry name.
* @param state - projected fiber state.
*/
set(id: string, state: LoaderEntryState): void
set: (id: string, state: LoaderEntryState) => void
}
/**

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 2f65b0d8f223c4de8999006d005778e7087e8a4d
README.zh.md: c0be5d7dc92a60c649b792bfa181c0df7a12db9f
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
README.zh.md: 2f7ccc7dd00fa5599d3d6bbe66e81d3312d38dd9

View File

@@ -10,16 +10,16 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -38,7 +38,7 @@ Every setting is optional. Top-level policy fields are defaults for every routed
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. |
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.

View File

@@ -10,16 +10,16 @@
该后端拥有压缩策略:
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤压力会包含实际系统提示词、工具、前缀、路由、assistant 完成、工具结果、缓冲上下文与 steering。
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤边界压力会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering。
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍只提供建议,不会被咨询。
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤检查绝不剪枝。
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤检查绝不剪枝。
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 保持工具调用/结果 cut 平衡。轮次边界不会保护失控轮次内的旧步骤。开启且不可分的尾部在关闭前会拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent智能体目标而不运行仅用于 loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`适配器可将其作为请求归因转发DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见主体。只有返回文本会进入检查点;会排除可能泄露私有推理或产生遗留调用的 reasoning 与工具调用。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
- **生命周期**`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/post-step` listener 会在成功输出与工具工作持久后、`step/end` 之前检查压力。规范提供方溢出会在失败步骤关闭后通过 `agent/request-error` 处理
- **生命周期**`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、精确目标上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性步骤后失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。
- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性压力失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。
受保护的 `summarize()` 方法是唯一的子类 hook。基于模板或远程摘要器的子类可以覆盖该方法同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍位于 `ctx.tokenMeter`。hook 会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
@@ -38,7 +38,7 @@
| `compactionRetries` | 否(默认 `1` | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 |
| `maxOverflowRetries` | 否(默认 `1` | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 |
| `modelPolicies` | 否(默认 `[]` | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 |
| `auto` | 否(默认 `true` | 注册步骤压力与溢出恢复 listener。设为 `false` 则仅手动执行。 |
| `auto` | 否(默认 `true` | 注册步骤边界压力与溢出恢复 listener。设为 `false` 则仅手动执行。 |
每个 `modelPolicies` 配置项都接受上述策略字段,但不接受 `auto``modelPolicies` 自身。如果配置项提供任意一个保留字段,就替换默认策略的保留选择;否则继承保留设置。摘要提供方/模型在每个配置项内仍然成对。

View File

@@ -111,6 +111,8 @@ export class BasicCompactService extends CompactService {
readonly config: ResolvedConfig
private readonly warnedPressureConfigTargets = new Set<string>()
private readonly overflowRetries = new WeakMap<Agent, number>()
private readonly overflowAgents = new WeakMap<Session, Agent>()
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
@@ -119,8 +121,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Register the automatic post-step pressure and context-overflow recovery
* listeners. `compactIfNeeded` stays dynamically dispatched so subclass
* Register automatic between-step pressure and model-request overflow
* recovery. `compactIfNeeded` stays dynamically dispatched so subclass
* overrides are honored at event time.
*/
private _registerAutomaticCompaction(): void {
@@ -133,7 +135,7 @@ export class BasicCompactService extends CompactService {
)
}
ctx.on('agent/post-step', async (
ctx.on('agent/step', async (
agent: Agent,
_turn: number,
_step: number,
@@ -142,36 +144,47 @@ export class BasicCompactService extends CompactService {
if (signal.aborted) return
try {
const result = await this.compactIfNeeded(agent, 'pressure', signal)
if (result !== null) logResult(result, 'post-step pressure')
if (result !== null) logResult(result, 'step pressure')
} catch (error: unknown) {
if (error instanceof TargetPressureConfigError) {
if (this.warnedPressureConfigTargets.has(error.targetKey)) return
this.warnedPressureConfigTargets.add(error.targetKey)
}
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
}
})
ctx.on('agent/settled', (agent) => {
this.overflowRetries.delete(agent)
})
// A successful response starts a fresh overflow-recovery sequence even
// when tool calls continue the same turn into another request.
ctx.on('session/event', (session, event) => {
if (event.type !== 'assistant/message') return
const agent = this.overflowAgents.get(session)
if (agent !== undefined) this.overflowRetries.delete(agent)
})
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
failure,
priorFailures,
_priorFailures,
_retryPolicy,
signal,
next,
) => {
const priorOverflowFailures = priorFailures.filter(
item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE,
).length
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
this.overflowAgents.set(agent.session, agent)
const target = routedTarget(agent.session)
if (target === undefined) return next()
const policy = resolveTargetPolicy(this.config, target)
if (priorOverflowFailures >= policy.maxOverflowRetries) return next()
const retries = this.overflowRetries.get(agent) ?? 0
if (retries >= policy.maxOverflowRetries) return next()
const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
@@ -182,27 +195,29 @@ export class BasicCompactService extends CompactService {
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
+ 'retrying from the replacement surface',
)
return { action: 'retry' }
this.overflowRetries.set(agent, retries + 1)
return { kind: 'retry' }
}
ctx.logger.warn(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
if (result !== null) logResult(result, 'context overflow recovery')
return { action: 'retry' }
this.overflowRetries.set(agent, retries + 1)
return { kind: 'retry' }
})
}
@@ -229,12 +244,12 @@ export class BasicCompactService extends CompactService {
}
/**
* Compact for replayed post-step pressure or one provider-confirmed context
* Compact for replayed step-boundary pressure or one provider-confirmed context
* overflow. Both triggers price the latest durable routed request envelope;
* overflow bypasses the normal threshold and retained-tail policy so it can
* force one useful balanced reduction.
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param trigger - normal step-boundary pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest summary compaction result, or `null` when no summary ran.
*/

View File

@@ -177,10 +177,10 @@ export async function compactSurfaceRegion(
/**
* Reconstruct the last routed request's cacheable prefix for the shadowed
* region: its system prompt and tool schemas, then the request-only message
* prefix followed by the region's own derived messages in surface order. The
* summarizer appends only the compaction instruction after this, so the call
* is a genuine prefix of the conversation and reuses the provider's KV cache.
* region: its system prompt and tool schemas, then the region's own derived
* messages in surface order. The summarizer appends only the compaction
* instruction after this, so the call is a genuine prefix of the conversation
* and reuses the provider's KV cache.
* @param session - session supplying the request header and per-node projection.
* @param shadowedSeqs - the surface-node seqs, in order, being compacted.
* @returns the replayed conversation prefix to condense.
@@ -199,7 +199,7 @@ function buildSummarizationInput(
return {
...header?.system === undefined ? {} : { system: header.system },
...header?.tools === undefined ? {} : { tools: header.tools },
messages: [...header?.messagePrefix ?? [], ...regionMessages],
messages: regionMessages,
}
}

View File

@@ -78,7 +78,7 @@ export interface SummarizationInput {
readonly system?: string
/** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */
readonly tools?: readonly ToolSchema[]
/** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */
/** The shadowed region, in surface order, that precedes the compaction instruction. */
readonly messages: readonly Message[]
}

View File

@@ -38,7 +38,7 @@ export interface ModelCompactPolicyConfig extends CompactPolicyConfig {
export interface BasicCompactConfig extends CompactPolicyConfig {
/** Exact provider/model overrides; duplicate targets fail plugin load. */
modelPolicies?: ModelCompactPolicyConfig[]
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
/** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
auto?: boolean
}

View File

@@ -22,7 +22,7 @@ import type {
} from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
const SIGNAL = new AbortController().signal
@@ -76,7 +76,10 @@ function createContext(contextWindow = 1_000): Context {
}
function agent(session: Session, model?: string): Agent {
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
return {
session,
options: model === undefined ? {} : { provider: model, model },
} as Agent
}
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
@@ -568,7 +571,7 @@ describe('pressure measurement and retention', () => {
expect(session.surface.nodes.length).toBeLessThan(8)
})
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
it('counts the durable routed request envelope without putting it on the surface', async () => {
const compact = service({
auto: false,
thresholdRatio: 0.9,
@@ -577,22 +580,15 @@ describe('pressure measurement and retention', () => {
const session = conversation(2, 'x'.repeat(600))
expect(await compactIfNeeded(compact, session)).toBeNull()
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
session.append('request/header', {
header: {
config: { provider: MODEL, model: MODEL },
system: 's'.repeat(600),
messagePrefix: prefix,
system: 's'.repeat(2_000),
},
reason: 'resume',
})
const result = await compactIfNeeded(compact, session)
expect(result).not.toBeNull()
expect(prefix).toHaveLength(1)
// The routed request prefix must not reach the surface as its own message
// (the compaction summary itself is an expected plugin-sourced checkpoint).
expect(session.events.some(event => event.type === 'user/message'
&& event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false)
})
it('uses the latest logged request envelope without an AgentOptions override', async () => {
@@ -822,13 +818,12 @@ describe('compaction region transaction', () => {
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
})
it('replays the latest routed header prefix so the summarizer reuses the cache', async () => {
it('replays the latest routed header so the summarizer reuses the cache', async () => {
const compact = service()
const session = conversation(3)
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }]
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix },
header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools },
reason: 'resume',
})
const nodes = session.surface.nodes
@@ -837,7 +832,6 @@ describe('compaction region transaction', () => {
const { input } = compact.calls[0]!
expect(input.system).toBe('CONVERSATION SYSTEM')
expect(input.tools).toEqual(tools)
expect(input.messages[0]).toEqual(messagePrefix[0])
expect(summarizedText(input)).toContain('fixture user 1')
})
@@ -1289,22 +1283,21 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => {
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
}
function recover(
ctx: Context,
owner: Agent,
error: Error & { code?: string },
retryAttempt = 0,
signal = SIGNAL,
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
): Promise<{ action: 'fail' | 'retry' }> {
next: () => Promise<RequestErrorAction> = () => Promise.resolve(undefined),
): Promise<boolean> {
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
return agentEvents(ctx, owner).waterfall(
'agent/request-error', 1, 1, error, failure, priorFailures, undefined, signal, next,
)
'agent/request-error', turn, 1, error, failure, [], undefined, signal, next,
).then(action => action?.kind === 'retry')
}
function overflow(message = 'provider overflow'): Error & { code: string } {
@@ -1413,7 +1406,7 @@ describe('automatic listener and loader composition', () => {
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
expect(decision).toEqual({ action: 'retry' })
expect(decision).toBe(true)
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.surface.nodes).toContain(retainedSeq)
@@ -1432,7 +1425,7 @@ describe('automatic listener and loader composition', () => {
})
const session = oversizedToolResult()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
@@ -1451,7 +1444,7 @@ describe('automatic listener and loader composition', () => {
})
const session = toolConversation()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
@@ -1473,7 +1466,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
@@ -1497,8 +1490,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(1)
})
@@ -1512,7 +1504,7 @@ describe('automatic listener and loader composition', () => {
const newestAssistant = session.surface.nodes.at(-2)!
const newestResult = session.surface.nodes.at(-1)!
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
const currentResult = session.surface.nodes.find(node => node === newestResult)
expect(currentAssistant).toBeDefined()
@@ -1536,7 +1528,7 @@ describe('automatic listener and loader composition', () => {
}
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.surface.replaceGeneration).toBe(0)
})
@@ -1551,7 +1543,6 @@ describe('automatic listener and loader composition', () => {
ctx,
agent(conversation(2), MODEL),
overflow(),
0,
SIGNAL,
() => {
calls += 1
@@ -1569,7 +1560,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable')
const original = overflow('original provider overflow')
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
expect(original).toMatchObject({
message: 'original provider overflow',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
@@ -1588,12 +1579,12 @@ describe('automatic listener and loader composition', () => {
const original = overflow('original provider failure')
let delegations = 0
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
delegations += 1
return Promise.resolve({ action: 'fail' })
return Promise.resolve(undefined)
})
expect(decision).toEqual({ action: 'fail' })
expect(decision).toBe(false)
expect(delegations).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation)
expect(original).toMatchObject({
@@ -1612,7 +1603,7 @@ describe('automatic listener and loader composition', () => {
reason: 'resume',
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
.toEqual({ action: 'retry' })
.toBe(true)
})
it('delegates canonical overflow when no durable routed target exists', async () => {
@@ -1624,21 +1615,19 @@ describe('automatic listener and loader composition', () => {
trigger: { kind: 'message', source: { kind: 'user' } },
})
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
})
it('honors retry caps, non-context failures, and cancellation', async () => {
it('honors retry caps and ignores non-context failures', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
const controller = new AbortController()
controller.abort('cancelled')
expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
.toBe(false)
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1653,9 +1642,11 @@ describe('automatic listener and loader composition', () => {
}],
})
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1667,8 +1658,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(3)
const generation = session.surface.replaceGeneration
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(generation + 1)
})
@@ -1683,7 +1673,7 @@ describe('automatic listener and loader composition', () => {
await postStep(ctx, agent(session, MODEL))
const summaries = session.events.filter(event => event.type === 'compact/summary').length
expect(summaries).toBe(1)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
})
@@ -1697,7 +1687,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
it('loads and disposes the real zero-config service stack', async () => {
@@ -1726,6 +1716,6 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
})

View File

@@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression through the real loop. A replacement checkpoint has a high
@@ -184,39 +184,43 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
})
}
function seedOverflowHistory(agent: Agent): void {
function overflowHistorySeed(): SessionEvent[] {
const session = new Session(SessionId('overflow-history-seed'))
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
agent.session.append('turn/start', {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
agent.session.append('user/message', {
session.append('user/message', {
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
agent.session.append('step/start', { turn, step: 1 })
agent.session.append('assistant/message', {
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
}, { surfaceOp: 'append' })
agent.session.append('step/end', { turn, step: 1 })
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return [...session.events]
}
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
try {
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
})
agent.followup([{ type: 'text', text: 'do a routed multi-step task' }])
agent.followup({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(agent.session.requestHeader()?.config.model).toBe('mock')
@@ -230,11 +234,11 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
}
})
it('runs automatic pressure after the current tool result and before step/end', async () => {
it('runs automatic pressure between the completed tool step and the next step', async () => {
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'do tool work' }])
agent.followup({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
@@ -244,13 +248,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
event.type === 'tool/result' && event.seq < compactStart!.seq,
)
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
const stepEnd = events.find(event =>
const precedingStepEnd = events.find(event =>
event.type === 'step/end'
&& event.data.step === precedingResult.data.step
&& event.seq > precedingResult.seq,
)
const nextStepStart = events.find(event =>
event.type === 'step/start'
&& event.data.step === precedingResult.data.step + 1
&& event.seq > compactStart!.seq,
)
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
} finally {
await ctx.fiber.dispose()
}
@@ -260,7 +270,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'do a long multi-step task' }])
agent.followup({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
@@ -300,7 +310,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
await ctx.plugin(BasicCompactService, {
thresholdRatio: 1,
retainTokens: 100,
@@ -310,13 +322,16 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
const { agent } = await ctx.agentLoop.createAgent(ctx, {
sessionId: SessionId(`overflow-${delivery}`),
seed: overflowHistorySeed(),
agentOptions: {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
},
})
seedOverflowHistory(agent)
agent.followup([{ type: 'text', text: 'continue from history' }])
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(2)
@@ -327,11 +342,17 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(retry).not.toContain('OLD HISTORY SENTINEL')
const events = [...agent.session.events]
const failedEnd = events.find(event =>
const failedStepEnd = events.find(event =>
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
)!
const failedEnd = events.find(event =>
event.type === 'turn/end' && event.data.turn === 3,
)!
const retryStart = events.find(event =>
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
event.type === 'turn/start' && event.data.turn === 4,
)!
const retryStep = events.find(event =>
event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
)!
const compaction = events.filter(event =>
event.type === 'compact/start'
@@ -343,7 +364,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
'compact/summary',
'compact/end',
])
expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
expect(compaction.every(event =>
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
)).toBe(true)
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
expect(events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -372,17 +397,20 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
seedOverflowHistory(agent)
agent.followup([{ type: 'text', text: 'continue from history' }])
const { agent } = await ctx.agentLoop.createAgent(ctx, {
sessionId: SessionId('alternating-recovery'),
seed: overflowHistorySeed(),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3)
expect(adapter.summaryRequests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
.toEqual([1, 2, 3])
.toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
.toEqual([3, 4, 5])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c
README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2
README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e
README.zh.md: 2c1bdd6e5b290a771094719daa6e4d7c3bf577db

View File

@@ -8,6 +8,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.

View File

@@ -8,6 +8,6 @@
|---|---|---|
| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` |
| `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute` |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute` |
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent会话隔离与生命周期拆分。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: c995256511742c193e064cf808fc89194b444974
README.zh.md: e2e67cfee745c84d6c85e8792e53c50bf2648293
# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md
README.md: 2ca461f88b266b4dec1ffa4132c8cb17455f4b8e
README.zh.md: 9f8fd0bace9b37b2f7885eded7686ecac8c625ba

View File

@@ -2,19 +2,19 @@
English | [中文](README.zh.md)
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly.
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly.
## 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 `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
- `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 calls `followup()` or `steer()`.
- `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
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for UI replay. Later source mutation, compaction, or deletion cannot change target replay.
The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The standard TUI preserves admission ownership without attaching context to the generic inbox record: outside the next-step acceptance window, a one-shot `agent/prompt-submit` wrapper adds the snapshot only to an allowed decision; during prompt admission or an open turn, `inject()` and `steer()` stage beside each other for the same safe boundary. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration
@@ -32,7 +32,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac
#### What the model sees
The model sees one user-role message in this order: the `## Referenced sessions` untrusted snapshot, the `## My request:` delimiter, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
#### Token effect
@@ -40,7 +40,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps
#### KV Cache effect
The combined snapshot and request are append-only at the target message boundary and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
The snapshot and request are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
## Known Limitations and Deferred Work

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为提示词前缀上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。
`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。
## 公开 API
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label并回退到会话 id不搜索标题与消息主体。
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `HookContext`。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()``steer()` 之前被拒绝。
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()``steer()` 之前被拒绝。
- `encodeSessionReferenceUri()``decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)``parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。
## 快照语义
准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的直接 user `user/message`、直接 user `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含烘焙前缀上下文的源提示词投影只读取其对模型隐藏的显示内容以防止快照递归传播。已遮蔽的压缩前事件、工具、reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant chunk 均会被排除。因此,已压缩源贡献的是最新检查点与之后保留的会话,而非已恢复的遮蔽文本。
上下文源为 `{ kind: 'plugin', plugin: 'session-reference' }`,并携带 `placement: 'prompt-prefix'`。其元数据会记录版本 `1`、源 id 与 label、捕获 seq、是否存在 compact、已保留已省略消息数、已省略 UTF-8 字节数与截断状态。AgentLoop 将快照、`## My request:` 分隔符和有效提示词写入同一个 `user/message``steering/message`;同一事件的模型隐藏 envelope 保留直接提示词与元数据,用于 UI 回放。后续源变更、压缩或删除都无法改变目标回放。
上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留已省略消息数、已省略 UTF-8 字节数与截断状态。标准 TUI 在不把上下文附加到通用 inbox 记录的情况下保留接纳归属next-step 接收窗口之外,一次性 `agent/prompt-submit` 包装层只为获准决策添加快照;提示词接纳期间或轮次打开时,`inject()``steer()` 会并排暂存到同一安全边界。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message``steering/message`。后续源变更、压缩或删除都无法改变目标回放。
## 配置
@@ -32,7 +32,7 @@
#### 模型看到的内容
模型会按此顺序看到一条 user 角色消息:`## Referenced sessions` 不受信任快照`## My request:` 分隔符,随后是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `<referenced-sessions>` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。
模型会看到两条连续的 user 角色消息:先是 `## Referenced sessions` 不受信任快照,再是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `<referenced-sessions>` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。
#### Token 影响
@@ -40,7 +40,7 @@
#### KV Cache 影响
组合快照与请求在目标消息边界处仅追加,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
## 已知限制与暂缓事项

View File

@@ -7,9 +7,9 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue, 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,
@@ -20,7 +20,7 @@ import {
} from './config.ts'
import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts'
import { stringifyTagSafeJson } from './serialization.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts'
export type * from './types.ts'
export type { Config, SessionReferenceErrorCode } from './config.ts'
@@ -148,7 +148,7 @@ export class SessionReferenceService extends Service {
* @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order.
* @param signal - optional cancellation boundary for host request teardown.
* @returns detached content and zero or one prepared contexts.
* @returns detached content and optional referenced-session context.
*/
async prepare(
agent: Agent,
@@ -158,7 +158,7 @@ export class SessionReferenceService extends Service {
): Promise<PreparedReferencedMessage> {
const acceptedContent = structuredClone(content)
const inputs = normalizeReferences(agent.id, references, this.config.maxReferences)
if (inputs.length === 0) return { content: acceptedContent, contexts: [] }
if (inputs.length === 0) return { content: acceptedContent }
assertNotCancelled(signal)
let prepared: PreparedSource[]
try {
@@ -181,7 +181,7 @@ export class SessionReferenceService extends Service {
const rendered = this.renderSources(prepared)
const prompt = renderPrompt(rendered.map(source => source.data))
const meta = {
const source: SessionReferenceSource = {
kind: 'session-reference',
version: 1,
references: rendered.map((source, index) => ({
@@ -191,14 +191,12 @@ export class SessionReferenceService extends Service {
...source.stats,
inputIndex: index,
})),
} satisfies JsonValue
const context: HookContext = {
source: { kind: 'plugin', plugin: 'session-reference' },
content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
meta,
}
return { content: acceptedContent, contexts: [context] }
const additionalContext: UserMessageData = {
source,
content: [{ type: 'text', text: prompt }],
}
return { content: acceptedContent, additionalContext }
}
private renderSources(sources: readonly PreparedSource[]): RenderedSource[] {

View File

@@ -1,7 +1,6 @@
/** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
@@ -41,13 +40,13 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
case 'user/message': {
const checkpoint = isCompactCheckpointSource(event.data.source)
if (!checkpoint && event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data))
const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
break
}
case 'steering/message': {
if (event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data))
const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}

View File

@@ -1,8 +1,31 @@
/** Public session-reference request, candidate, and preparation records. */
import type { HookContext } 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 {
kind: 'session-reference'
version: 1
references: {
sessionId: string
label: string
capturedThroughSeq: number | null
compacted: boolean
originalMessages: number
retainedMessages: number
omittedMessages: number
omittedBytes: number
truncated: boolean
inputIndex: number
}[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'session-reference': SessionReferenceSource
}
}
/** One source session selected by a host. */
export interface SessionReferenceInput {
@@ -24,12 +47,12 @@ export interface SessionReferenceCandidate {
createdAt: number
}
/** Message payload and the zero-or-one durable snapshot contexts bound to it. */
/** Direct message content and optional referenced-session context. */
export interface PreparedReferencedMessage {
/** Readable message content after host mention tokens are removed. */
content: ContentBlock[]
/** Empty without references; otherwise one aggregated untrusted context. */
contexts: HookContext[]
/** Aggregated untrusted snapshot, absent when the message has no references. */
additionalContext?: UserMessageData
}
/** Text-only projected conversation item. */

View File

@@ -241,11 +241,9 @@ describe('session reference discovery and preparation', () => {
[{ sessionId: source.id, label: 'source' }],
)
expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }])
expect(prepared.contexts).toHaveLength(1)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.source).toMatchObject({ kind: 'session-reference' })
expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{
sessionId: 'source',
@@ -259,7 +257,7 @@ describe('session reference discovery and preparation', () => {
{ role: 'assistant', text: 'visible answer' },
],
}])
expect(context.meta).toMatchObject({
expect(context.source).toMatchObject({
kind: 'session-reference',
version: 1,
references: [{
@@ -279,21 +277,17 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).not.toContain('later source mutation')
})
it('projects only the direct prompt when a source message contains baked prefix context', async () => {
it('excludes injected context when projecting a referenced session', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
source.append('user/message', {
content: [
{ type: 'text', text: 'nested referenced snapshot must not propagate' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'direct source question' },
],
content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }],
source: { kind: 'plugin', plugin: 'session-reference' },
}, { surfaceOp: 'append' })
source.append('user/message', {
content: [{ type: 'text', text: 'direct source question' }],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'direct source question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' })
const prepared = await ctx.sessionReferences.prepare(
@@ -301,7 +295,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(promptData(context.content[0].text)).toMatchObject([{
conversation: [{ role: 'user', text: 'direct source question' }],
@@ -325,7 +319,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const prompt = context.content[0].text
expect(prompt).toMatch(/^## Referenced sessions\n/u)
@@ -350,14 +344,14 @@ describe('session reference discovery and preparation', () => {
const content = [{ type: 'text' as const, text: 'go' }]
const withoutReferences = await ctx.sessionReferences.prepare(agent, content, [])
expect(withoutReferences).toEqual({ content, contexts: [] })
expect(withoutReferences).toEqual({ content })
expect(withoutReferences.content).not.toBe(content)
await expect(ctx.sessionReferences.prepare(agent, content, [
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] })
])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
@@ -428,14 +422,14 @@ describe('session reference discovery and preparation', () => {
)
const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360)
expect(context.content[0].text).toContain('checkpoint')
expect(context.content[0].text).toContain('latest-')
expect(context.content[0].text).toContain('omitted')
expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] })
expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] })
})
it('applies the full byte limit independently to each of three references', async () => {
@@ -462,7 +456,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })),
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8'))
@@ -495,18 +489,12 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context === undefined) throw new Error('expected prepared context')
target.append('user/message', context, { surfaceOp: 'append' })
target.append('user/message', {
content: [...context.content, { type: 'text', text: '\n\n## My request:\n' }, ...prepared.content],
content: prepared.content,
source: { kind: 'user' },
envelope: {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}],
},
}, { surfaceOp: 'append' })
const before = target.deriveMessages()
@@ -533,7 +521,7 @@ describe('session reference discovery and preparation', () => {
expect(ctx.sessions.get(source.id)).toBeUndefined()
expect(target.deriveMessages()).toEqual(before)
expect(JSON.stringify(before)).toContain('durable referenced fact')
expect(JSON.stringify(before)).toContain('## My request:')
expect(JSON.stringify(before)).toContain('use @source')
expect(JSON.stringify(before)).not.toContain('later source mutation')
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: db6d4e2cc9b68f94fe7cacd85c302c4242c88930
README.zh.md: 06e13824109f76242aaae2d302e984a8598cbc98
README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10
README.zh.md: 337ce3613d7b17134db7cf85a808881017f4e2c3

View File

@@ -20,7 +20,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.

Some files were not shown because too many files have changed in this diff Show More