feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
This commit is contained in:
@@ -285,6 +285,25 @@ export function inactiveRows(tree: EntryTree): string[] {
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The reportable text of a mount failure.
|
||||
*
|
||||
* The loader reports several failed rows as one `AggregateError`, whose own
|
||||
* message names none of them; without flattening, a composition that fails on
|
||||
* two rows says only "loader entries failed to apply" and the operator has
|
||||
* nothing to act on.
|
||||
* @param error - the value the mount rejected with.
|
||||
* @returns a single-line-per-cause description.
|
||||
*/
|
||||
function mountDetail(error: unknown): string {
|
||||
/* v8 ignore next -- every path into the mount's catch throws an Error: the loader
|
||||
wraps a row's thrown value before it propagates, and this module's own
|
||||
rejections are Errors. The fallback keeps a hostile value readable. */
|
||||
if (!(error instanceof Error)) return String(error)
|
||||
if (!(error instanceof AggregateError)) return error.message
|
||||
return [error.message, ...error.errors.map(cause => `- ${mountDetail(cause)}`)].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount `preset` under `agentCtx` and return only once every row is usable.
|
||||
*
|
||||
@@ -341,10 +360,6 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
|
||||
// Swallows only this subtree's teardown failure. The mount error below is
|
||||
// the actionable one, and the discarded fiber is unreachable either way.
|
||||
}
|
||||
/* v8 ignore next -- every path into this catch throws an Error: the loader
|
||||
wraps a row's thrown value before it propagates, and this module's own
|
||||
rejections are Errors. The fallback keeps a hostile value readable. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new PresetMountError(preset.id, `${detail} (${preset.path})`, { cause: error })
|
||||
throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,21 @@ describe('display order', () => {
|
||||
// alphabetical behind them rather than interleaving unpredictably.
|
||||
expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee'])
|
||||
})
|
||||
|
||||
it('breaks a tie between equal declared orders by id', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-'))
|
||||
for (const id of ['yankee', 'alpha']) {
|
||||
await mkdir(join(root, id), { recursive: true })
|
||||
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
|
||||
await writeFile(join(root, id, 'preset.yml'), 'order: 1\n')
|
||||
}
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'system' })
|
||||
|
||||
// Two presets claiming the same slot must still list in a stable order:
|
||||
// a directory-scan order would reshuffle the picker between reads.
|
||||
expect(found.map(preset => preset.id)).toEqual(['alpha', 'yankee'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('preset discovery', () => {
|
||||
|
||||
7
packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml
vendored
Normal file
7
packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Two rows that cannot load: the Loader reports several failed entries as one
|
||||
# AggregateError whose own message names none of them, so this fixture is what
|
||||
# proves the mount diagnostic flattens the causes.
|
||||
- id: first-missing
|
||||
name: ../../plugins/does-not-exist.js
|
||||
- id: second-missing
|
||||
name: ../../plugins/also-missing.js
|
||||
@@ -95,6 +95,10 @@ describe('rendering display metadata', () => {
|
||||
expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' })
|
||||
})
|
||||
|
||||
it('stores a declared order', () => {
|
||||
expect(renderPresetMetadata({ name: '标准模式', order: 1 })).toBe('name: 标准模式\norder: 1\n')
|
||||
})
|
||||
|
||||
it('omits an absent field rather than writing it blank', () => {
|
||||
expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n')
|
||||
// Description without a name is legal too: the picker falls back to the id.
|
||||
|
||||
@@ -146,6 +146,14 @@ describe('rejecting a composition that cannot be used', () => {
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('names every failed row, not just the count', async () => {
|
||||
// The Loader folds several failed rows into one AggregateError whose own
|
||||
// message names none of them; unflattened, the operator is told only that
|
||||
// "loader entries failed to apply" and has nothing to act on.
|
||||
await expect(agentOn(ctx, 'sess-two-broken', 'two-broken'))
|
||||
.rejects.toThrow(/first-missing[\s\S]*second-missing/)
|
||||
})
|
||||
|
||||
it('names the unresolved service when a row never activates', async () => {
|
||||
await expect(agentOn(ctx, 'sess-pending', 'pending'))
|
||||
.rejects.toThrow(/waiting for serviceThatDoesNotExist/)
|
||||
@@ -204,7 +212,7 @@ describe('the preset roster', () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.map(preset => preset.id).sort())
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard'])
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken'])
|
||||
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
|
||||
})
|
||||
|
||||
|
||||
62
packages/preset/agent-presets/tests/session.spec.ts
Normal file
62
packages/preset/agent-presets/tests/session.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Which preset a session ran is a question about its LOG, not its header: the
|
||||
* header records the creation-time choice, and a switch made during the blank
|
||||
* window is an event. Every reconstruction — the list row, the header label,
|
||||
* resume, fork — goes through this resolver, so a resolver that read the header
|
||||
* alone would rebuild a switched session under a composition its own history
|
||||
* contradicts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { resolveSessionPreset } from '../src/session.ts'
|
||||
|
||||
/** A header carrying the creation-time preset, if any. */
|
||||
function header(agentPreset?: string): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: SessionId('s'),
|
||||
createdAt: 1,
|
||||
delegationDepth: 0,
|
||||
...agentPreset === undefined ? {} : { agentPreset },
|
||||
}
|
||||
}
|
||||
|
||||
/** One logged selection, as `agentPreset.select` appends it. */
|
||||
function selected(agentPreset: string, seq: number): SessionEvent {
|
||||
return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } }
|
||||
}
|
||||
|
||||
describe('resolving which preset a session ran', () => {
|
||||
it('reads the creation-time value when nothing was switched', () => {
|
||||
expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard')
|
||||
})
|
||||
|
||||
it('prefers a logged switch over the header', () => {
|
||||
// The switch's effect outlives the blank window it was made in: the turns
|
||||
// that follow run under the newer composition.
|
||||
expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] }))
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('takes the last switch when a session was moved twice', () => {
|
||||
expect(resolveSessionPreset({
|
||||
header: header('standard'),
|
||||
events: [selected('minimal', 0), selected('cordis', 1)],
|
||||
})).toBe('cordis')
|
||||
})
|
||||
|
||||
it('finds a switch behind later events', () => {
|
||||
const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent
|
||||
|
||||
expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] }))
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports none when the deployment composes no presets', () => {
|
||||
// A valid deployment: every session shares the host composition, and no
|
||||
// surface should invent a preset name for it.
|
||||
expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user