@fonlan/dsh-task-kanban 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
3
- import { api } from './api'
3
+ import { api, type PresetOption, type ReasoningOptions } from './api'
4
4
  import { toggleBoard, useBoardOpen } from './kanban-state'
5
- import type { KanbanSettingsShape } from '../shared/card'
5
+ import type { KanbanSettingsShape, ModelOption } from '../shared/card'
6
6
 
7
7
  interface FooterActionProps {
8
8
  wide?: boolean
@@ -140,17 +140,141 @@ export function KanbanFooterButton({ wide, t }: FooterActionProps): JSX.Element
140
140
  )
141
141
  }
142
142
 
143
- interface SettingsSectionProps {
143
+ interface SettingsCardProps {
144
144
  t?: (key: string) => string
145
145
  }
146
146
 
147
- /** Plugin settings section: global parallel workers + default model. */
148
- export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element {
147
+ /** Fallback shape while settings load (server always resolves the full set). */
148
+ const EMPTY_SETTINGS: KanbanSettingsShape = {
149
+ maxParallelWorkers: 1,
150
+ refinementModel: '',
151
+ refinementProvider: '',
152
+ refinementReasoningEffort: '',
153
+ refinementPreset: '',
154
+ phaseModel: '',
155
+ phaseProvider: '',
156
+ phaseReasoningEffort: '',
157
+ phasePreset: '',
158
+ }
159
+
160
+ /** The setting field names of one session-type group. */
161
+ const GROUP_FIELDS = {
162
+ refinement: {
163
+ model: 'refinementModel',
164
+ provider: 'refinementProvider',
165
+ effort: 'refinementReasoningEffort',
166
+ preset: 'refinementPreset',
167
+ },
168
+ phase: {
169
+ model: 'phaseModel',
170
+ provider: 'phaseProvider',
171
+ effort: 'phaseReasoningEffort',
172
+ preset: 'phasePreset',
173
+ },
174
+ } as const
175
+
176
+ type SessionGroupKey = keyof typeof GROUP_FIELDS
177
+
178
+ interface SessionDefaultsGroupProps {
179
+ kind: SessionGroupKey
180
+ settings: KanbanSettingsShape
181
+ models: ModelOption[]
182
+ presets: PresetOption[]
183
+ /** Reasoning efforts already fetched, keyed `provider/model`. */
184
+ efforts: Record<string, ReasoningOptions>
185
+ tr: (key: string) => string
186
+ onCommit: (patch: Partial<KanbanSettingsShape>) => Promise<void>
187
+ loadEfforts: (provider: string, model: string) => void
188
+ }
189
+
190
+ /** Model / reasoning effort / agent preset defaults for one session type. */
191
+ function SessionDefaultsGroup({ kind, settings, models, presets, efforts, tr, onCommit, loadEfforts }: SessionDefaultsGroupProps): JSX.Element {
192
+ const fields = GROUP_FIELDS[kind]
193
+ const model = settings[fields.model]
194
+ const provider = settings[fields.provider]
195
+ const effort = settings[fields.effort]
196
+ const preset = settings[fields.preset]
197
+ const effortKey = model !== '' && provider !== '' ? `${provider}/${model}` : ''
198
+ const effortOptions = effortKey !== '' ? (efforts[effortKey]?.efforts ?? []) : []
199
+
200
+ const handleModel = (id: string): void => {
201
+ const found = models.find((m) => m.id === id)
202
+ const nextProvider = found?.provider ?? ''
203
+ void onCommit({ [fields.model]: id, [fields.provider]: nextProvider } as Partial<KanbanSettingsShape>)
204
+ if (id !== '' && nextProvider !== '') loadEfforts(nextProvider, id)
205
+ }
206
+
207
+ return (
208
+ <div className="kb-settings-group">
209
+ <div className="kb-settings-group-title">{tr(kind === 'refinement' ? 'settingsRefineGroup' : 'settingsPhaseGroup')}</div>
210
+ <label className="kb-field kb-field-row">
211
+ <span>{tr('model')}</span>
212
+ <select className="kb-input" value={model} onChange={(e) => handleModel(e.target.value)}>
213
+ <option value="">{tr('defaultOption')}</option>
214
+ {models.map((m) => (
215
+ <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
216
+ ))}
217
+ </select>
218
+ </label>
219
+ <label className="kb-field kb-field-row">
220
+ <span>{tr('reasoningEffort')}</span>
221
+ <select
222
+ className="kb-input"
223
+ value={effort}
224
+ disabled={model === ''}
225
+ onChange={(e) => void onCommit({ [fields.effort]: e.target.value } as Partial<KanbanSettingsShape>)}
226
+ >
227
+ <option value="">{tr('defaultOption')}</option>
228
+ {effortOptions.map((o) => (
229
+ <option key={o.id} value={o.id}>{o.name}</option>
230
+ ))}
231
+ </select>
232
+ </label>
233
+ <label className="kb-field kb-field-row">
234
+ <span>{tr('agentPreset')}</span>
235
+ <select
236
+ className="kb-input"
237
+ value={preset}
238
+ onChange={(e) => void onCommit({ [fields.preset]: e.target.value } as Partial<KanbanSettingsShape>)}
239
+ >
240
+ <option value="">{tr('defaultOption')}</option>
241
+ {presets.map((p) => (
242
+ <option key={p.id} value={p.id}>{p.name ?? p.id}</option>
243
+ ))}
244
+ </select>
245
+ </label>
246
+ </div>
247
+ )
248
+ }
249
+
250
+ /** Plugin settings card (设置 → 插件配置): workers + default model + per-session-type defaults. */
251
+ export function KanbanSettingsCard({ t }: SettingsCardProps): JSX.Element {
149
252
  const tr = t ?? ((key: string) => key)
150
- const [settings, setSettings] = useState<KanbanSettingsShape>({ maxParallelWorkers: 1, defaultModel: '' })
151
- const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
253
+ // Card-local disclosure: collapsed by default, like the built-in plugin cards.
254
+ const [open, setOpen] = useState(false)
255
+ const [settings, setSettings] = useState<KanbanSettingsShape>(EMPTY_SETTINGS)
256
+ const [models, setModels] = useState<ModelOption[]>([])
257
+ const [presets, setPresets] = useState<PresetOption[]>([])
258
+ const [efforts, setEfforts] = useState<Record<string, ReasoningOptions>>({})
152
259
  const [saved, setSaved] = useState(false)
153
260
 
261
+ const commit = useCallback(async (patch: Partial<KanbanSettingsShape>): Promise<void> => {
262
+ try {
263
+ const next = await api.settingsSet(patch)
264
+ setSettings(next)
265
+ setSaved(true)
266
+ window.setTimeout(() => setSaved(false), 1500)
267
+ } catch {
268
+ // keep the previous value
269
+ }
270
+ }, [])
271
+
272
+ // Refs so async callbacks (effort fetch) read the freshest settings/commit.
273
+ const settingsRef = useRef(settings)
274
+ settingsRef.current = settings
275
+ const commitRef = useRef(commit)
276
+ commitRef.current = commit
277
+
154
278
  useEffect(() => {
155
279
  let alive = true
156
280
  api.settingsGet()
@@ -159,51 +283,123 @@ export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element
159
283
  api.models()
160
284
  .then((list) => { if (alive) setModels(list) })
161
285
  .catch(() => undefined)
286
+ api.presets()
287
+ .then((list) => { if (alive) setPresets(list) })
288
+ .catch(() => undefined)
162
289
  return () => { alive = false }
163
290
  }, [])
164
291
 
165
- const commit = async (patch: Partial<KanbanSettingsShape>): Promise<void> => {
166
- try {
167
- const next = await api.settingsSet(patch)
168
- setSettings(next)
169
- setSaved(true)
170
- window.setTimeout(() => setSaved(false), 1500)
171
- } catch {
172
- // keep the previous value
292
+ /** Reasoning routes already requested (dedupe across mount + model changes). */
293
+ const effortFetched = useRef<Set<string>>(new Set())
294
+
295
+ /**
296
+ * Fetch the reasoning efforts of one model route (once per route). A failed
297
+ * fetch is marked `failed` so the stored effort is preserved (see the reset
298
+ * effect below) instead of being wiped by a transient error.
299
+ */
300
+ const loadEfforts = useCallback((provider: string, model: string): void => {
301
+ const key = `${provider}/${model}`
302
+ if (effortFetched.current.has(key)) return
303
+ effortFetched.current.add(key)
304
+ api.reasoningOptions(provider, model)
305
+ .then((opts) => setEfforts((prev) => ({ ...prev, [key]: opts })))
306
+ .catch(() => setEfforts((prev) => ({ ...prev, [key]: { efforts: [], failed: true } })))
307
+ }, [])
308
+
309
+ // Fetch efforts for the models already configured in settings, so the
310
+ // effort selects show their levels (and validate the stored effort) as soon
311
+ // as settings load — the fetch previously ran only on model change.
312
+ useEffect(() => {
313
+ for (const kind of ['refinement', 'phase'] as const) {
314
+ const fields = GROUP_FIELDS[kind]
315
+ const model = settings[fields.model]
316
+ const provider = settings[fields.provider]
317
+ if (model !== '' && provider !== '') loadEfforts(provider, model)
173
318
  }
174
- }
319
+ }, [settings.refinementModel, settings.refinementProvider, settings.phaseModel, settings.phaseProvider, loadEfforts])
320
+
321
+ // When the efforts of the selected model arrive, drop a stored effort the
322
+ // model no longer offers (e.g. the user switched the model). A failed fetch
323
+ // (`failed: true`) keeps the stored effort: the model may just be
324
+ // temporarily unresolvable, and wiping the user's choice would be worse.
325
+ useEffect(() => {
326
+ const s = settingsRef.current
327
+ for (const kind of ['refinement', 'phase'] as const) {
328
+ const fields = GROUP_FIELDS[kind]
329
+ const model = s[fields.model]
330
+ const provider = s[fields.provider]
331
+ const effort = s[fields.effort]
332
+ if (effort === '' || model === '' || provider === '') continue
333
+ const opts = efforts[`${provider}/${model}`]
334
+ if (opts === undefined || opts.failed === true) continue
335
+ if (!opts.efforts.some((o) => o.id === effort)) {
336
+ void commitRef.current({ [fields.effort]: '' } as Partial<KanbanSettingsShape>)
337
+ }
338
+ }
339
+ }, [efforts])
175
340
 
176
341
  return (
177
- <div className="kb-settings">
178
- <label className="kb-field kb-field-row">
179
- <span>{tr('maxParallelWorkers')}</span>
180
- <input
181
- className="kb-input kb-input-number"
182
- type="number"
183
- min={1}
184
- step={1}
185
- value={settings.maxParallelWorkers}
186
- onChange={(e) => {
187
- const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
188
- setSettings((s) => ({ ...s, maxParallelWorkers: value }))
189
- void commit({ maxParallelWorkers: value })
190
- }}
191
- />
192
- </label>
193
- <label className="kb-field kb-field-row">
194
- <span>{tr('defaultModel')}</span>
195
- <select
196
- className="kb-input"
197
- value={settings.defaultModel}
198
- onChange={(e) => { void commit({ defaultModel: e.target.value }) }}
199
- >
200
- <option value="">—</option>
201
- {models.map((m) => (
202
- <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
203
- ))}
204
- </select>
205
- </label>
206
- {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
207
- </div>
342
+ <li className="kb-settings-card" data-open={open ? '' : undefined}>
343
+ <button
344
+ type="button"
345
+ className="kb-settings-card-head"
346
+ aria-expanded={open}
347
+ aria-label={(open ? tr('settingsCollapse') : tr('settingsExpand')) + ':' + tr('settingsTitle')}
348
+ onClick={() => setOpen(!open)}
349
+ >
350
+ <span className="kb-settings-card-headText">
351
+ <span className="kb-settings-card-title">{tr('settingsTitle')}</span>
352
+ <span className="kb-settings-card-sub">{tr('settingsDescription')}</span>
353
+ </span>
354
+ <span className="kb-settings-card-chevron" data-open={open ? '' : undefined} aria-hidden="true">
355
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
356
+ <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
357
+ strokeLinecap="round" strokeLinejoin="round" />
358
+ </svg>
359
+ </span>
360
+ </button>
361
+ {open && (
362
+ <div className="kb-settings-card-body">
363
+ <div className="kb-settings">
364
+ <label className="kb-field kb-field-row">
365
+ <span>{tr('maxParallelWorkers')}</span>
366
+ <input
367
+ className="kb-input kb-input-number"
368
+ type="number"
369
+ min={1}
370
+ step={1}
371
+ value={settings.maxParallelWorkers}
372
+ onChange={(e) => {
373
+ const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
374
+ setSettings((s) => ({ ...s, maxParallelWorkers: value }))
375
+ void commit({ maxParallelWorkers: value })
376
+ }}
377
+ />
378
+ </label>
379
+ <SessionDefaultsGroup
380
+ kind="refinement"
381
+ settings={settings}
382
+ models={models}
383
+ presets={presets}
384
+ efforts={efforts}
385
+ tr={tr}
386
+ onCommit={commit}
387
+ loadEfforts={loadEfforts}
388
+ />
389
+ <SessionDefaultsGroup
390
+ kind="phase"
391
+ settings={settings}
392
+ models={models}
393
+ presets={presets}
394
+ efforts={efforts}
395
+ tr={tr}
396
+ onCommit={commit}
397
+ loadEfforts={loadEfforts}
398
+ />
399
+ {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
400
+ </div>
401
+ </div>
402
+ )}
403
+ </li>
208
404
  )
209
405
  }
@@ -18,3 +18,43 @@ export async function listModels(ctx: Context): Promise<ModelOption[]> {
18
18
  }
19
19
  return out
20
20
  }
21
+
22
+ /** One selectable reasoning effort of a model (display vocabulary for the settings picker). */
23
+ export interface ReasoningEffortOption {
24
+ id: string
25
+ name: string
26
+ description?: string
27
+ }
28
+
29
+ export interface ReasoningOptions {
30
+ efforts: ReasoningEffortOption[]
31
+ defaultEffort?: string
32
+ }
33
+
34
+ /**
35
+ * Resolve the adapter-owned reasoning efforts of one exact provider/model
36
+ * route. On-demand (one model at a time) so the settings picker can offer the
37
+ * levels the selected model actually supports; failures yield an empty list.
38
+ */
39
+ export async function reasoningOptions(ctx: Context, provider: string, model: string): Promise<ReasoningOptions> {
40
+ const llm = ctx.get('llm') as
41
+ | { resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
42
+ reasoning?: { efforts?: Array<{ id: unknown; name: string; description?: string }>; defaultEffort?: unknown }
43
+ }> }
44
+ | undefined
45
+ if (llm === undefined) return { efforts: [] }
46
+ try {
47
+ const info = await llm.resolveModelInfo(provider, model)
48
+ const efforts = (info.reasoning?.efforts ?? []).map((e) => ({
49
+ id: String(e.id),
50
+ name: e.name,
51
+ ...(e.description !== undefined ? { description: e.description } : {}),
52
+ }))
53
+ return {
54
+ efforts,
55
+ ...(info.reasoning?.defaultEffort !== undefined ? { defaultEffort: String(info.reasoning.defaultEffort) } : {}),
56
+ }
57
+ } catch {
58
+ return { efforts: [] }
59
+ }
60
+ }
package/src/server/rpc.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  import type { IncomingMessage, ServerResponse } from 'node:http'
7
7
  import type { Context } from '@deepseek-ai/cordis'
8
8
  import type {} from '@deepseek-ai/dsh-host-webserver'
9
- import type { Lane } from '../shared/card.js'
9
+ import type { KanbanSettingsShape, Lane } from '../shared/card.js'
10
10
  import type { KanbanRunner } from './runner.js'
11
11
  import type { KanbanSettingsFace } from './settings.js'
12
- import { listModels } from './models.js'
12
+ import { listModels, reasoningOptions } from './models.js'
13
13
 
14
14
  const API_PREFIX = '/plugins/@fonlan/dsh-task-kanban/api'
15
15
 
@@ -152,16 +152,45 @@ export function registerApiRoutes(
152
152
 
153
153
  route('settings.get', async () => settings.get())
154
154
 
155
+ /** Every settings field the client may write (per-session-type defaults included). */
156
+ const SETTING_STRING_FIELDS = [
157
+ 'refinementModel',
158
+ 'refinementProvider',
159
+ 'refinementReasoningEffort',
160
+ 'refinementPreset',
161
+ 'phaseModel',
162
+ 'phaseProvider',
163
+ 'phaseReasoningEffort',
164
+ 'phasePreset',
165
+ ] as const
166
+
155
167
  route('settings.set', async (p) => {
156
- const patch: { maxParallelWorkers?: number; defaultModel?: string } = {}
168
+ const patch: Partial<KanbanSettingsShape> = {}
157
169
  if (typeof p.maxParallelWorkers === 'number') patch.maxParallelWorkers = p.maxParallelWorkers
158
- if (typeof p.defaultModel === 'string') patch.defaultModel = p.defaultModel
170
+ for (const key of SETTING_STRING_FIELDS) {
171
+ if (typeof p[key] === 'string') patch[key] = p[key]
172
+ }
159
173
  await settings.update(patch)
160
174
  return settings.get()
161
175
  })
162
176
 
163
177
  route('models.list', async () => listModels(ctx))
164
178
 
179
+ route('presets.list', async () => {
180
+ const presets = ctx.get('agentPresets') as
181
+ | { list(): Promise<Array<{ id: string; name?: string; description?: string }>> }
182
+ | undefined
183
+ if (presets === undefined) return []
184
+ const rows = await presets.list()
185
+ return rows.map((r) => ({ id: r.id, name: r.name, description: r.description }))
186
+ })
187
+
188
+ route('reasoning.options', async (p) => {
189
+ const provider = requireString(p.provider, 'provider')
190
+ const model = requireString(p.model, 'model')
191
+ return reasoningOptions(ctx, provider, model)
192
+ })
193
+
165
194
  return () => {
166
195
  for (const d of disposers) d()
167
196
  }
@@ -10,9 +10,9 @@ import { appendFile, readFile } from 'node:fs/promises'
10
10
  import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
11
11
  import { join } from 'node:path'
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
14
- import type { AgentHandle } from '@deepseek-ai/dsh-agent'
15
- import type { KanbanCard, ErrorKind, Lane, Plan } from '../shared/card.js'
13
+ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
14
+ import { installModelSelection, type AgentHandle, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
15
+ import type { KanbanCard, ErrorKind, KanbanSessionKind, Lane, Plan } from '../shared/card.js'
16
16
  import type { TaskStore } from './task-store.js'
17
17
  import type { KanbanSettingsFace } from './settings.js'
18
18
  import { currentBranch, detectBaseRef, hasStashMessage, isGitRepo, isTreeDirty, revParse, runGit, unmergedPaths } from './git.js'
@@ -425,7 +425,7 @@ export class KanbanRunner implements KanbanToolResolver {
425
425
  private async runRefinement(cardId: string, wsPath: string): Promise<void> {
426
426
  const card = await this.store.get(wsPath, cardId)
427
427
  if (card === undefined) return
428
- const route = await this.modelRoute(cardId, wsPath)
428
+ const route = await this.modelRoute(cardId, wsPath, 'refine')
429
429
  const sessionId = this.newSessionId()
430
430
  this.sessionCards.set(sessionId, cardId)
431
431
  await this.store.mutate(cardId, (c) => {
@@ -434,7 +434,7 @@ export class KanbanRunner implements KanbanToolResolver {
434
434
  c.sessions.refinement.push(sessionId)
435
435
  }, wsPath)
436
436
  try {
437
- const handle = await this.createAgent(sessionId, wsPath, route)
437
+ const handle = await this.createAgent(sessionId, wsPath, route, 'refine')
438
438
  this.keepHandle(cardId, handle)
439
439
  await this.attachRefinementSession(wsPath, sessionId)
440
440
  // The agent scope is the layer where preset skill providers live
@@ -556,18 +556,54 @@ export class KanbanRunner implements KanbanToolResolver {
556
556
  return { promise, dispose }
557
557
  }
558
558
 
559
+ /**
560
+ * Create one kanban session agent. `kind` decides the per-session preset:
561
+ * refine/phase use their settings default (falling back to the roster's
562
+ * default preset when unset), merge keeps the historical 'standard' preset.
563
+ * When the kind defaults configure a reasoning effort, an
564
+ * `installModelSelection` ref couples the provider/model + effort into every
565
+ * prompt assembly and model request. Without a configured effort no
566
+ * selection is installed: `agentOptions` carries the route and the loop
567
+ * materializes the model's own default effort — the historical behavior.
568
+ */
559
569
  private async createAgent(
560
570
  sessionId: SessionId,
561
571
  cwd: string,
562
- route: { provider?: string; model?: string },
572
+ route: { provider?: string; model?: string; reasoningEffort?: string },
573
+ kind: KanbanSessionKind,
563
574
  ) {
564
575
  const agentOptions =
565
576
  route.provider !== undefined && route.model !== undefined && route.model !== ''
566
577
  ? { provider: route.provider, model: route.model }
567
578
  : undefined
579
+ const selectionRef: ModelSelectionRef = {
580
+ current:
581
+ route.provider !== undefined && route.model !== undefined && route.model !== ''
582
+ ? {
583
+ provider: route.provider,
584
+ model: route.model,
585
+ ...(route.reasoningEffort !== undefined && route.reasoningEffort !== ''
586
+ ? { reasoningEffort: ReasoningEffortId(route.reasoningEffort) }
587
+ : {}),
588
+ }
589
+ : undefined,
590
+ assembled: undefined,
591
+ }
592
+ // Resolve the preset id BEFORE creation so the session header records the
593
+ // exact composition it runs under (a resumed kanban session then rebuilds
594
+ // the same preset instead of the deployment default).
595
+ let presetId: string | undefined
596
+ const presets = this.ctx.get('agentPresets') as
597
+ | { resolve(id?: string): Promise<{ id: string }> }
598
+ | undefined
599
+ if (presets !== undefined) {
600
+ const requested = kind === 'merge' ? 'standard' : this.settings.sessionDefaults(kind).preset
601
+ const resolved = await presets.resolve(requested === '' ? undefined : requested)
602
+ presetId = resolved.id
603
+ }
568
604
  return this.ctx.agents.create({
569
605
  sessionId,
570
- meta: { cwd },
606
+ meta: { cwd, ...(presetId !== undefined ? { agentPreset: presetId } : {}) },
571
607
  ...(agentOptions !== undefined ? { agentOptions } : {}),
572
608
  // Kanban tools are NOT registered here: `agent/session-start` (see
573
609
  // start()) fires for both create and resume, keeping them available
@@ -577,10 +613,13 @@ export class KanbanRunner implements KanbanToolResolver {
577
613
  | { mount(agentCtx: unknown, id?: string): Promise<unknown> }
578
614
  | undefined
579
615
  if (agentPresets !== undefined) {
580
- await agentPresets.mount(agentCtx, 'standard')
616
+ await agentPresets.mount(agentCtx, presetId)
581
617
  } else {
582
618
  throw new Error('@fonlan/dsh-task-kanban: agent-presets service is not mounted')
583
619
  }
620
+ if (selectionRef.current !== undefined && selectionRef.current.reasoningEffort !== undefined) {
621
+ installModelSelection(agentCtx, selectionRef)
622
+ }
584
623
  },
585
624
  })
586
625
  }
@@ -619,8 +658,19 @@ export class KanbanRunner implements KanbanToolResolver {
619
658
  return undefined
620
659
  }
621
660
 
622
- /** Resolve the provider+model route for an agent (both are required). */
623
- private async modelRoute(cardId: string, wsPath: string): Promise<{ provider?: string; model?: string }> {
661
+ /**
662
+ * Resolve the provider+model route for an agent (both are required), plus
663
+ * the per-kind reasoning effort. Precedence per kind:
664
+ * 1. the card's explicit model (chosen at creation) — wins for every kind;
665
+ * 2. the session-kind defaults (refine/phase settings);
666
+ * 3. the host `agent-default-model` route.
667
+ * Merge sessions skip step 2 (no per-type defaults for them).
668
+ */
669
+ private async modelRoute(
670
+ cardId: string,
671
+ wsPath: string,
672
+ kind: KanbanSessionKind,
673
+ ): Promise<{ provider?: string; model?: string; reasoningEffort?: string }> {
624
674
  const card = await this.store.get(wsPath, cardId)
625
675
  if (card === undefined) return {}
626
676
  if (card.model !== '' && card.provider !== undefined && card.provider !== '') {
@@ -630,10 +680,13 @@ export class KanbanRunner implements KanbanToolResolver {
630
680
  const fallback = this.settings.defaultModelRoute()
631
681
  return fallback.provider !== undefined ? { provider: fallback.provider, model: card.model } : { model: card.model }
632
682
  }
633
- const userDefault = this.settings.get().defaultModel
634
- if (userDefault !== '') {
635
- const fallback = this.settings.defaultModelRoute()
636
- return fallback.provider !== undefined ? { provider: fallback.provider, model: userDefault } : { model: userDefault }
683
+ const kindDefaults = this.settings.sessionDefaults(kind)
684
+ if (kindDefaults.model !== '') {
685
+ return {
686
+ ...(kindDefaults.provider !== '' ? { provider: kindDefaults.provider } : {}),
687
+ model: kindDefaults.model,
688
+ ...(kindDefaults.reasoningEffort !== '' ? { reasoningEffort: kindDefaults.reasoningEffort } : {}),
689
+ }
637
690
  }
638
691
  return this.settings.defaultModelRoute()
639
692
  }
@@ -731,9 +784,9 @@ export class KanbanRunner implements KanbanToolResolver {
731
784
  c.error = undefined
732
785
  c.sessions.phases.push({ phaseIndex, sessionId, startedAt: Date.now() })
733
786
  }, wsPath)
734
- const route = await this.modelRoute(cardId, wsPath)
787
+ const route = await this.modelRoute(cardId, wsPath, 'phase')
735
788
  try {
736
- const handle = await this.createAgent(sessionId, workdir, route)
789
+ const handle = await this.createAgent(sessionId, workdir, route, 'phase')
737
790
  this.keepHandle(cardId, handle)
738
791
  entry.cancel = () => handle.agent.cancel({ kind: 'user' })
739
792
  handle.agent.followup(this.asUserMessage(phasePrompt({ ...(await this.store.get(wsPath, cardId)) ?? { plan } as KanbanCard, plan } as KanbanCard, phaseIndex, workdir)))
@@ -984,9 +1037,9 @@ export class KanbanRunner implements KanbanToolResolver {
984
1037
  const sessionId = this.newSessionId()
985
1038
  this.sessionCards.set(sessionId, cardId)
986
1039
  await this.store.mutate(cardId, (c) => { c.sessions.merge.push(sessionId) }, wsPath)
987
- const route = await this.modelRoute(cardId, wsPath)
1040
+ const route = await this.modelRoute(cardId, wsPath, 'merge')
988
1041
  try {
989
- const handle = await this.createAgent(sessionId, workdir, route)
1042
+ const handle = await this.createAgent(sessionId, workdir, route, 'merge')
990
1043
  this.keepHandle(cardId, handle)
991
1044
  handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)))
992
1045
  await handle.agent.whenIdle()