@cat-factory/app 0.194.0 → 0.195.1

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.
@@ -23,10 +23,12 @@ import {
23
23
  INITIATIVE_STATUS_LABEL_KEYS,
24
24
  initiativeProgress,
25
25
  pendingCheckpointPhase,
26
+ planReviewDocument,
26
27
  } from '~/utils/initiative'
27
28
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
28
29
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
29
30
  import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
31
+ import InitiativePlanNotice from '~/components/initiative/InitiativePlanNotice.vue'
30
32
 
31
33
  const board = useBoardStore()
32
34
  const initiatives = useInitiativesStore()
@@ -61,6 +63,24 @@ const {
61
63
  stepIndex: () => stepIndex.value,
62
64
  })
63
65
 
66
+ /**
67
+ * The `StepRunMeta` prop bundle, or null when no step speaks for this window. Bound as one object
68
+ * because it has two homes — the tracker's end-side column, and the plan review's sidebar while the
69
+ * review owns the window — and the run details must read identically in both.
70
+ */
71
+ const runMeta = computed(() =>
72
+ metaStep.value
73
+ ? {
74
+ step: metaStep.value,
75
+ instanceId: runId.value,
76
+ stepNumber: position.value,
77
+ totalSteps: totalSteps.value,
78
+ runFailed: runFailed.value,
79
+ failureAt: failureAt.value,
80
+ }
81
+ : null,
82
+ )
83
+
64
84
  const phases = computed(() => initiative.value?.phases ?? [])
65
85
  function itemsOf(phaseId: string): InitiativeItem[] {
66
86
  return (initiative.value?.items ?? []).filter((i) => i.phaseId === phaseId)
@@ -103,9 +123,19 @@ async function checkpointControl(action: 'resume' | 'cancel') {
103
123
  // ---- Plan review: the planner step's human gate, resolved right here -----------------------
104
124
  // Derived from the BLOCK (via the shared planning composable), not from this window's own
105
125
  // `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
106
- // human parked on the gate actually uses. So the rail appears on every route into the window.
126
+ // human parked on the gate actually uses. So the review appears on every route into the window.
107
127
  const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
128
 
129
+ /**
130
+ * The plan document the parked gate offers, or `''` — which is also the layout decision. A rendered
131
+ * plan is a REPLACEMENT for the tracker body rather than a card above it: the render reads the
132
+ * ingested entity, so the sections below would be a second copy of what the reviewer is reading,
133
+ * and everything the tracker adds on top (PR links, item curation, checkpoints, follow-ups) is
134
+ * execution-time state that cannot exist until the plan is committed. With no document, the tracker
135
+ * body IS the plan, so the gate takes the compact notice above it instead.
136
+ */
137
+ const planDocument = computed(() => planReviewDocument(planApproval.value))
138
+
109
139
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
110
140
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
111
141
  const axes = [
@@ -236,30 +266,54 @@ async function savePolicy() {
236
266
  </UBadge>
237
267
  </template>
238
268
 
239
- <div class="flex min-h-0 flex-1">
269
+ <!-- The planner's human gate, with the plan rendered as a document. This window is where the
270
+ park ROUTES (the planner's archetype declares this result view), so it is the only surface
271
+ that can resolve it — and while it is parked the review OWNS the window: an outline sidebar,
272
+ the plan at full height, per-block commenting and the commands in an end-side rail, the same
273
+ tools and the same shape the step reader gives the architect's prose. The tracker body it
274
+ replaces would only repeat the plan (see `planDocument`). -->
275
+ <InitiativePlanReview
276
+ v-if="planApproval && planDocument"
277
+ :approval="planApproval.approval"
278
+ :instance-id="planApproval.instanceId"
279
+ :can-execute="access.canExecuteRuns.value"
280
+ :plan-document="planDocument"
281
+ >
282
+ <template v-if="runMeta" #run-details>
283
+ <StepRunMeta v-bind="runMeta" />
284
+ </template>
285
+ </InitiativePlanReview>
286
+
287
+ <div v-else class="flex min-h-0 flex-1">
240
288
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
241
- <!-- No entity yet (module unwired / still creating) -->
289
+ <!-- A parked gate whose step rendered no plan: the commands, plus a notice pointing at the
290
+ sections below — which in that case are the only rendering of the plan there is.
291
+ Deliberately OUTSIDE the entity branch: the gate lives on the RUN, so it is parked
292
+ before `initiatives.load()` has resolved (and stays parked if it fails), and a window
293
+ that answered such a gate with the empty state alone would leave it unresolvable from
294
+ the UI. `hasSections` is what keeps the notice honest about whether the sections it
295
+ points at are actually rendered underneath. -->
296
+ <InitiativePlanNotice
297
+ v-if="planApproval"
298
+ :approval="planApproval.approval"
299
+ :instance-id="planApproval.instanceId"
300
+ :can-execute="access.canExecuteRuns.value"
301
+ :has-sections="!!initiative"
302
+ />
303
+
304
+ <!-- No entity yet (module unwired / still creating). Centred in the column when it is the
305
+ only thing in it; merely inset when the notice above it means `h-full` would overflow
306
+ the scroller by the notice's own height. -->
242
307
  <div
243
308
  v-if="!initiative"
244
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
309
+ class="flex flex-col items-center justify-center gap-2 text-center text-slate-400"
310
+ :class="planApproval ? 'py-16' : 'h-full'"
245
311
  >
246
312
  <UIcon name="i-lucide-milestone" class="h-8 w-8 opacity-40" />
247
313
  <p class="text-sm">{{ t('initiative.tracker.empty') }}</p>
248
314
  </div>
249
315
 
250
316
  <template v-else>
251
- <!-- The planner's human gate. This window is where the park ROUTES (the planner's
252
- archetype declares this result view), so it is the only surface that can resolve
253
- it — and the plan it judges is rendered as a navigable document with per-block
254
- commenting, the same tools the step reader gives the architect's prose. -->
255
- <InitiativePlanReview
256
- v-if="planApproval"
257
- :approval="planApproval.approval"
258
- :instance-id="planApproval.instanceId"
259
- :can-execute="access.canExecuteRuns.value"
260
- :output-is-rendered="planApproval.outputIsRendered"
261
- />
262
-
263
317
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
264
318
  review before the next phase spawns. Read the phase's artifacts/PRs below,
265
319
  then resume (continue) or cancel (stop) the initiative right here. -->
@@ -646,18 +700,11 @@ async function savePolicy() {
646
700
  through `useResultViewRunMeta`, so it is present on the card / inspector entry point
647
701
  too — where this window carries no step index of its own. -->
648
702
  <aside
649
- v-if="metaStep"
703
+ v-if="runMeta"
650
704
  data-testid="initiative-tracker-run-meta"
651
705
  class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
652
706
  >
653
- <StepRunMeta
654
- :step="metaStep"
655
- :instance-id="runId"
656
- :step-number="position"
657
- :total-steps="totalSteps"
658
- :run-failed="runFailed"
659
- :failure-at="failureAt"
660
- />
707
+ <StepRunMeta v-bind="runMeta" />
661
708
  </aside>
662
709
  </div>
663
710
  </ResultWindowShell>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
3
3
  import type { AgentPromptRevision } from '~/types/agent-prompts'
4
4
  import { agentKindMeta } from '~/utils/catalog'
5
5
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
6
+ import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
6
7
  import {
7
8
  draftForRevision,
8
9
  isDirty,
@@ -30,6 +31,7 @@ const emit = defineEmits<{ close: [] }>()
30
31
  const { t } = useI18n()
31
32
  const toast = useToast()
32
33
  const prompts = useAgentPromptsStore()
34
+ const agentSettings = useAgentSettingsStore()
33
35
 
34
36
  const open = computed({
35
37
  get: () => props.agentKind !== null,
@@ -76,6 +78,29 @@ watch(
76
78
  const detail = computed(() => prompts.detail)
77
79
  const label = computed(() => (props.agentKind ? agentKindMeta(props.agentKind).label : ''))
78
80
 
81
+ /**
82
+ * The workspace-wide output-token ceiling for this kind — the same per-agent-kind scope this
83
+ * editor already owns for the prompt, which is why it lives here rather than in a settings screen
84
+ * of its own. A pipeline step may still pin its own budget over it.
85
+ *
86
+ * Saved on its own, immediately: unlike the prompt (whose save appends a revision and wants an
87
+ * explicit commit) this is one scalar with no history, so a separate Save button would only invite
88
+ * someone to type a number, close the modal and wonder why nothing changed.
89
+ */
90
+ const budget = computed(() =>
91
+ props.agentKind ? agentSettings.maxOutputTokensFor(props.agentKind) : undefined,
92
+ )
93
+
94
+ async function saveBudget(value: number | null) {
95
+ const kind = props.agentKind
96
+ if (!kind) return
97
+ try {
98
+ await agentSettings.setMaxOutputTokens(kind, value)
99
+ } catch {
100
+ toast.add({ title: t('agentPrompt.toast.budgetFailed'), color: 'error' })
101
+ }
102
+ }
103
+
79
104
  /** The live prompt, so "no change" can be reported instead of appending an identical revision. */
80
105
  const dirty = computed(() => isDirty(draft.value, detail.value))
81
106
  /** Nothing to revert to when the workspace is already running the shipped prompt. */
@@ -164,6 +189,21 @@ function revisionLabel(revision: AgentPromptRevision): string {
164
189
  </UBadge>
165
190
  </div>
166
191
 
192
+ <!-- The workspace-wide output ceiling for this kind. Same per-agent-kind scope as the
193
+ prompt below it; saves on change, since there is no revision log to commit to. -->
194
+ <div class="flex flex-wrap items-center gap-2">
195
+ <span class="text-[11px] text-slate-400">{{ t('pipeline.outputBudget.kindLabel') }}</span>
196
+ <OutputBudgetInput
197
+ class="w-32"
198
+ :model-value="budget"
199
+ :disabled="agentSettings.saving"
200
+ @update:model-value="saveBudget"
201
+ />
202
+ <span class="text-[10px] text-slate-500">
203
+ {{ t('pipeline.outputBudget.kindHint') }}
204
+ </span>
205
+ </div>
206
+
167
207
  <!-- What the platform appends is SHOWN, not described. A prose summary of it is copy
168
208
  that silently goes stale the moment a directive is added, and a user who does not
169
209
  know what is already there writes a prompt that fights it. -->
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The output-token ceiling control, shared by both tiers that configure one: a pipeline step's
4
+ * own override (in the builder) and a workspace's per-agent-kind default (in the prompt editor).
5
+ *
6
+ * Empty means INHERIT, and that is the whole reason this is a component rather than a bare
7
+ * `UInput type="number"` at each call site: "no value" has to round-trip as `null` and never as
8
+ * `0`, or clearing the field would send a zero-token ceiling — every reply empty — instead of
9
+ * falling back to the next tier. The bounds come from the contract, so the input cannot offer a
10
+ * value the server would reject.
11
+ */
12
+ import { computed } from 'vue'
13
+ import { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '~/types/agent-settings'
14
+
15
+ const props = defineProps<{
16
+ /** The configured ceiling, or null/undefined when this tier inherits. */
17
+ modelValue: number | null | undefined
18
+ /** What this tier falls back to, shown as the placeholder so "inherit" names a number. */
19
+ inheritedValue?: number | undefined
20
+ disabled?: boolean
21
+ }>()
22
+
23
+ const emit = defineEmits<{ 'update:modelValue': [number | null] }>()
24
+
25
+ const { t, n } = useI18n()
26
+
27
+ /** Bound to the input as a string so an empty field is distinguishable from a typed 0. */
28
+ const text = computed(() => (props.modelValue != null ? String(props.modelValue) : ''))
29
+
30
+ /**
31
+ * The placeholder names the inherited ceiling when the caller knows it, so a user reading an
32
+ * empty field learns what the step will actually run on rather than just that it is unset.
33
+ */
34
+ const placeholder = computed(() =>
35
+ props.inheritedValue != null
36
+ ? t('pipeline.outputBudget.inheritsValue', { tokens: n(props.inheritedValue) })
37
+ : t('pipeline.outputBudget.inherits'),
38
+ )
39
+
40
+ /**
41
+ * Commit on change. An empty/unparseable field clears to `null` (inherit); anything else is
42
+ * clamped into the contract's range rather than rejected, so a fat-fingered extra digit lands on
43
+ * the ceiling instead of silently failing the save with a 422.
44
+ */
45
+ function commit(raw: string | number) {
46
+ const trimmed = String(raw).trim()
47
+ if (trimmed === '') return emit('update:modelValue', null)
48
+ const parsed = Number.parseInt(trimmed, 10)
49
+ if (!Number.isFinite(parsed)) return emit('update:modelValue', null)
50
+ const clamped = Math.min(
51
+ MAX_AGENT_MAX_OUTPUT_TOKENS,
52
+ Math.max(MIN_AGENT_MAX_OUTPUT_TOKENS, parsed),
53
+ )
54
+ emit('update:modelValue', clamped)
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <UInput
60
+ :model-value="text"
61
+ type="number"
62
+ size="xs"
63
+ :min="MIN_AGENT_MAX_OUTPUT_TOKENS"
64
+ :max="MAX_AGENT_MAX_OUTPUT_TOKENS"
65
+ :placeholder="placeholder"
66
+ :disabled="disabled"
67
+ @change="commit(($event.target as HTMLInputElement).value)"
68
+ />
69
+ </template>
@@ -5,6 +5,7 @@ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
7
  import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
8
+ import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
8
9
  import { showOverrideField } from '~/utils/uiMode'
9
10
  import {
10
11
  agentKindMeta,
@@ -86,6 +87,7 @@ const agents = useAgentsStore()
86
87
  const ui = useUiStore()
87
88
  const uiMode = useUiModeStore()
88
89
  const agentPrompts = useAgentPromptsStore()
90
+ const agentSettings = useAgentSettingsStore()
89
91
 
90
92
  // The agent kind whose system prompt is open in the editor (null = closed). Per-KIND, not
91
93
  // per-step: an override applies to every run of that agent in the workspace, so two steps of
@@ -102,6 +104,26 @@ const promptEditorKind = ref<AgentKind | null>(null)
102
104
  function showPromptEditor(kind: AgentKind): boolean {
103
105
  return showOverrideField(uiMode.isAdvanced, agentPrompts.isCustomized(kind) || null)
104
106
  }
107
+
108
+ /**
109
+ * Whether this step's own output-budget field shows. Same override rule as the prompt editor: a
110
+ * step with no pinned ceiling inherits, so the control is advanced-only until a value actually
111
+ * exists — at which point it must be visible in BOTH tiers, or a basic-mode user runs on a budget
112
+ * a teammate pinned and they can neither see nor clear.
113
+ */
114
+ function showOutputBudget(index: number): boolean {
115
+ return showOverrideField(uiMode.isAdvanced, pipelines.draftMaxOutputTokens(index) ?? null)
116
+ }
117
+
118
+ /**
119
+ * What a step with no pinned ceiling actually runs on: the workspace's per-kind setting when it
120
+ * has one. Shown as the field's placeholder. Undefined ⇒ the deployment default, which the SPA
121
+ * deliberately does NOT guess at — it is env-resolved per agent kind and a number invented here
122
+ * would be wrong on the deployments that tuned it.
123
+ */
124
+ function inheritedOutputBudget(kind: AgentKind): number | undefined {
125
+ return agentSettings.maxOutputTokensFor(kind)
126
+ }
105
127
  const releaseHealth = useReleaseHealthStore()
106
128
  const skills = useSkillsStore()
107
129
 
@@ -151,6 +173,9 @@ watch(open, (isOpen) => {
151
173
  // Best-effort: the builder is fully usable without it, and a deployment that wires no
152
174
  // override store answers 503 here.
153
175
  if (isOpen) agentPrompts.loadIndex().catch(() => {})
176
+ // The workspace's per-kind output ceilings, which the per-step field shows as its inherited
177
+ // placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
178
+ if (isOpen) agentSettings.load().catch(() => {})
154
179
  })
155
180
 
156
181
  function add(kind: AgentKind) {
@@ -694,6 +719,23 @@ async function clone(p: Pipeline) {
694
719
  </p>
695
720
  </div>
696
721
 
722
+ <!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
723
+ setting (itself an override of the deployment routing default), so it is
724
+ advanced-only until a value is pinned; empty inherits. -->
725
+ <div v-if="showOutputBudget(unit.index)" class="ms-6 flex items-center gap-2">
726
+ <span class="text-[10px] text-slate-500">
727
+ {{ t('pipeline.outputBudget.stepLabel') }}
728
+ </span>
729
+ <OutputBudgetInput
730
+ class="w-28"
731
+ :model-value="pipelines.draftMaxOutputTokens(unit.index)"
732
+ :inherited-value="inheritedOutputBudget(unit.kind)"
733
+ @update:model-value="
734
+ pipelines.setDraftMaxOutputTokens(unit.index, $event ?? undefined)
735
+ "
736
+ />
737
+ </div>
738
+
697
739
  <!-- Attached companion: a dependent reviewer for this producer, optionally
698
740
  gated on the task estimate. -->
699
741
  <div
@@ -0,0 +1,32 @@
1
+ import {
2
+ listWorkspaceAgentSettingsContract,
3
+ updateWorkspaceAgentSettingsContract,
4
+ } from '@cat-factory/contracts'
5
+ import type { UpdateWorkspaceAgentSettingsInput } from '~/types/agent-settings'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * The workspace's per-agent-kind generation settings, edited from the pipeline builder. There is
10
+ * no delete route: clearing the last configured field IS the way back to the deployment default,
11
+ * and the server drops the row when nothing is left set.
12
+ */
13
+ export function agentSettingsApi({ send, ws }: ApiContext) {
14
+ return {
15
+ // Every kind the workspace has configured — nothing for a kind that inherits.
16
+ listWorkspaceAgentSettings: (workspaceId: string) =>
17
+ send(listWorkspaceAgentSettingsContract, { pathPrefix: ws(workspaceId) }),
18
+
19
+ // Patch one kind. An explicit null clears the field; the response is null once the kind is
20
+ // back to inheriting entirely.
21
+ updateWorkspaceAgentSettings: (
22
+ workspaceId: string,
23
+ agentKind: string,
24
+ body: UpdateWorkspaceAgentSettingsInput,
25
+ ) =>
26
+ send(updateWorkspaceAgentSettingsContract, {
27
+ pathPrefix: ws(workspaceId),
28
+ pathParams: { agentKind },
29
+ body,
30
+ }),
31
+ }
32
+ }
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
3
3
  import type { ApiContext } from './api/context'
4
4
  import { accountsApi } from './api/accounts'
5
5
  import { agentPromptsApi } from './api/agentPrompts'
6
+ import { agentSettingsApi } from './api/agentSettings'
6
7
  import { platformObservabilityApi } from './api/platformObservability'
7
8
  import { reportsApi } from './api/reports'
8
9
  import { authApi } from './api/auth'
@@ -136,6 +137,7 @@ export function useApi() {
136
137
  ...notificationsApi(ctx),
137
138
  ...presetsApi(ctx),
138
139
  ...agentPromptsApi(ctx),
140
+ ...agentSettingsApi(ctx),
139
141
  ...preflightsApi(ctx),
140
142
  ...publicApiKeysApi(ctx),
141
143
  ...sharedStacksApi(ctx),
@@ -0,0 +1,86 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { WorkspaceAgentSettings } from '~/types/agent-settings'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * The workspace's per-agent-kind generation settings — the pipeline builder's output-budget
8
+ * control, sitting beside the prompt editor.
9
+ *
10
+ * Unlike the prompt store there is only ONE shape to load: the whole configured set is a handful
11
+ * of small rows (a kind that inherits has no row at all), so the index IS the detail and the
12
+ * builder can badge every step and populate its editor from one request. There is nothing here
13
+ * worth deferring the way a prompt body is.
14
+ */
15
+ export const useAgentSettingsStore = defineStore('agentSettings', () => {
16
+ const api = useApi()
17
+
18
+ const settings = ref<WorkspaceAgentSettings[]>([])
19
+ const loading = ref(false)
20
+ const saving = ref(false)
21
+
22
+ /** Configured ceilings by agent kind, for O(1) lookup while rendering a pipeline's steps. */
23
+ const ceilingByKind = computed(() => {
24
+ const out = new Map<string, number>()
25
+ for (const row of settings.value) {
26
+ if (row.maxOutputTokens != null) out.set(row.agentKind, row.maxOutputTokens)
27
+ }
28
+ return out
29
+ })
30
+
31
+ /** This kind's configured ceiling, or undefined when it inherits the deployment default. */
32
+ function maxOutputTokensFor(agentKind: string): number | undefined {
33
+ return ceilingByKind.value.get(agentKind)
34
+ }
35
+
36
+ /**
37
+ * Load the configured set. Best-effort like the prompt index: the builder is fully usable
38
+ * without it (every kind simply runs the deployment ceiling), and the endpoint 503s on a
39
+ * deployment that wires no settings store at all.
40
+ */
41
+ async function load() {
42
+ const ws = useWorkspaceStore()
43
+ if (!ws.workspaceId) return
44
+ loading.value = true
45
+ try {
46
+ settings.value = await api.listWorkspaceAgentSettings(ws.requireId())
47
+ } finally {
48
+ loading.value = false
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Set (or clear, with `null`) one kind's output-token ceiling.
54
+ *
55
+ * Reconciles from the row the SERVER returned rather than the value sent: it answers `null`
56
+ * once the kind is back to inheriting, which is the same signal the row should disappear —
57
+ * so a clear and a set both land through one code path and the store can never keep a row the
58
+ * server has dropped.
59
+ */
60
+ async function setMaxOutputTokens(agentKind: string, maxOutputTokens: number | null) {
61
+ const ws = useWorkspaceStore()
62
+ saving.value = true
63
+ try {
64
+ const updated = await api.updateWorkspaceAgentSettings(ws.requireId(), agentKind, {
65
+ maxOutputTokens,
66
+ })
67
+ const rest = settings.value.filter((s) => s.agentKind !== agentKind)
68
+ settings.value = updated
69
+ ? [...rest, updated].sort((a, b) => a.agentKind.localeCompare(b.agentKind))
70
+ : rest
71
+ return updated
72
+ } finally {
73
+ saving.value = false
74
+ }
75
+ }
76
+
77
+ return {
78
+ settings,
79
+ loading,
80
+ saving,
81
+ ceilingByKind,
82
+ maxOutputTokensFor,
83
+ load,
84
+ setMaxOutputTokens,
85
+ }
86
+ })
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { usePipelinesStore } from '~/stores/pipelines'
3
+
4
+ /**
5
+ * The per-step output-token ceiling rides the shared `StepOptions` bag, so its helpers owe the
6
+ * same normalization every other field in there follows: merge rather than clobber, and drop the
7
+ * whole entry once the bag empties. That last part is what keeps an all-default pipeline from
8
+ * persisting a `step_options` array of empty objects.
9
+ */
10
+ describe('pipelines store — per-step output budget', () => {
11
+ it('sets, reads and clears a draft step’s ceiling', () => {
12
+ const pipelines = usePipelinesStore()
13
+ pipelines.addToDraft('doc-researcher')
14
+ expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
15
+
16
+ pipelines.setDraftMaxOutputTokens(0, 24_000)
17
+ expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
18
+ expect(pipelines.draftStepOptions[0]).toEqual({ maxOutputTokens: 24_000 })
19
+
20
+ // Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
21
+ // so a step back on the inherited budget persists no options at all.
22
+ pipelines.setDraftMaxOutputTokens(0, undefined)
23
+ expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
24
+ expect(pipelines.draftStepOptions[0]).toBeNull()
25
+ })
26
+
27
+ it('merges with the other options on the step rather than clobbering the bag', () => {
28
+ const pipelines = usePipelinesStore()
29
+ pipelines.addToDraft('requirements-review')
30
+ pipelines.toggleDraftAutoRecommend(0)
31
+ expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
32
+
33
+ pipelines.setDraftMaxOutputTokens(0, 12_000)
34
+ expect(pipelines.draftStepOptions[0]).toEqual({
35
+ autoRecommend: false,
36
+ maxOutputTokens: 12_000,
37
+ })
38
+
39
+ // And clearing one field leaves the other standing (the entry stays, since the bag is not empty).
40
+ pipelines.setDraftMaxOutputTokens(0, undefined)
41
+ expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
42
+ })
43
+
44
+ it('keeps each step’s ceiling independent', () => {
45
+ const pipelines = usePipelinesStore()
46
+ pipelines.addToDraft('doc-researcher')
47
+ pipelines.addToDraft('doc-outliner')
48
+
49
+ pipelines.setDraftMaxOutputTokens(0, 24_000)
50
+ pipelines.setDraftMaxOutputTokens(1, 10_000)
51
+
52
+ expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
53
+ expect(pipelines.draftMaxOutputTokens(1)).toBe(10_000)
54
+ })
55
+ })
@@ -139,6 +139,27 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
139
139
  draftStepOptions.value[index] = Object.keys(next).length ? next : null
140
140
  }
141
141
 
142
+ /**
143
+ * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
144
+ * inherits (the workspace's per-kind setting, else the deployment default).
145
+ */
146
+ function draftMaxOutputTokens(index: number): number | undefined {
147
+ return draftStepOptions.value[index]?.maxOutputTokens
148
+ }
149
+
150
+ /**
151
+ * Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
152
+ * bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
153
+ * entry — so a step back on the inherited budget persists no options at all, exactly like the
154
+ * other fields here.
155
+ */
156
+ function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
157
+ const next: StepOptions = { ...draftStepOptions.value[index] }
158
+ if (maxOutputTokens != null) next.maxOutputTokens = maxOutputTokens
159
+ else delete next.maxOutputTokens
160
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
161
+ }
162
+
142
163
  return {
143
164
  toggleDraftGating,
144
165
  toggleDraftConsensus,
@@ -153,5 +174,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
153
174
  toggleDraftAutoRecommend,
154
175
  draftSkillId,
155
176
  setDraftSkillId,
177
+ draftMaxOutputTokens,
178
+ setDraftMaxOutputTokens,
156
179
  }
157
180
  }
@@ -0,0 +1,12 @@
1
+ // Per-workspace, per-agent-kind generation settings, mirroring `@cat-factory/contracts`
2
+ // (agent-settings.ts). Today one knob: the output-token ceiling a kind's inline calls run under.
3
+ // A kind absent from the store inherits the deployment routing default, and a pipeline step's own
4
+ // `stepOptions.maxOutputTokens` still overrides whatever is set here.
5
+ //
6
+ // All wire shapes are sourced from @cat-factory/contracts (single source of truth).
7
+
8
+ export type {
9
+ UpdateWorkspaceAgentSettingsInput,
10
+ WorkspaceAgentSettings,
11
+ } from '@cat-factory/contracts'
12
+ export { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '@cat-factory/contracts'
@@ -9,6 +9,7 @@ import {
9
9
  isPendingQuestion,
10
10
  orderInterviewQuestions,
11
11
  pendingCheckpointPhase,
12
+ planReviewDocument,
12
13
  selectPlanApproval,
13
14
  } from './initiative'
14
15
 
@@ -205,6 +206,48 @@ describe('selectPlanApproval', () => {
205
206
  })
206
207
  })
207
208
 
209
+ // Which shape the plan gate takes in the tracker window: a document review that OWNS the window, or
210
+ // the compact notice above the tracker's own sections. Both the window's layout and the review
211
+ // surface read this one value, so these pin the cases where "there is a plan to read" is not the
212
+ // same as "the proposal is non-empty".
213
+
214
+ describe('planReviewDocument', () => {
215
+ const gate = (proposal: string | null | undefined, outputIsRendered: boolean) => ({
216
+ approval: { proposal },
217
+ outputIsRendered,
218
+ })
219
+
220
+ it('is the proposal when the step says it IS the plan rendering', () => {
221
+ expect(planReviewDocument(gate('# Initiative plan\n\n## Goal', true))).toBe(
222
+ '# Initiative plan\n\n## Goal',
223
+ )
224
+ })
225
+
226
+ it('returns the proposal verbatim, so comment anchors stay on the lines they quote', () => {
227
+ // Anchoring is by SOURCE LINE, so trimming a leading newline would shift every anchor up one.
228
+ expect(planReviewDocument(gate('\n# Initiative plan\n', true))).toBe('\n# Initiative plan\n')
229
+ })
230
+
231
+ it('reads an un-rendered proposal as no document, however substantial it looks', () => {
232
+ // The planner's transcript summary: a perfectly non-empty string that is not the plan. Showing
233
+ // it under a table of contents is the failure the rendered review exists to end.
234
+ expect(
235
+ planReviewDocument(gate('I drafted a three-phase plan and stopped for review.', false)),
236
+ ).toBe('')
237
+ })
238
+
239
+ it('reads a rendered but blank proposal as no document', () => {
240
+ expect(planReviewDocument(gate(' \n ', true))).toBe('')
241
+ expect(planReviewDocument(gate(null, true))).toBe('')
242
+ expect(planReviewDocument(gate(undefined, true))).toBe('')
243
+ })
244
+
245
+ it('has no document when nothing is parked', () => {
246
+ expect(planReviewDocument(null)).toBe('')
247
+ expect(planReviewDocument(undefined)).toBe('')
248
+ })
249
+ })
250
+
208
251
  /**
209
252
  * These tables are the reason the initiative card and the inspector word one park identically,
210
253
  * and they are exactly the shape both i18n drift guards are blind to: the typed-key check and