@cat-factory/app 0.300.1 → 0.301.0

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.
Files changed (41) hide show
  1. package/README.md +21 -0
  2. package/app/components/assistant/AssistantModal.logic.spec.ts +129 -1
  3. package/app/components/assistant/AssistantModal.logic.ts +83 -1
  4. package/app/components/assistant/AssistantModal.vue +124 -17
  5. package/app/components/board/AddTaskModal.vue +1 -2
  6. package/app/components/board/CreateInitiativeModal.vue +2 -3
  7. package/app/components/board/RecurringPipelineModal.vue +1 -2
  8. package/app/components/documents/DocumentSourceConnectModal.vue +2 -2
  9. package/app/components/documents/StartFromDesignModal.vue +1 -2
  10. package/app/components/layout/CommandBar.vue +1 -2
  11. package/app/components/panels/inspector/ServiceTestingContext.logic.spec.ts +38 -0
  12. package/app/components/panels/inspector/ServiceTestingContext.logic.ts +37 -0
  13. package/app/components/panels/inspector/ServiceTestingContext.vue +141 -0
  14. package/app/components/pipeline/PipelineBuilder.vue +6 -6
  15. package/app/components/providers/PersonalCredentialModal.vue +3 -3
  16. package/app/components/tasks/BugHuntModal.vue +1 -2
  17. package/app/components/tasks/TaskImportModal.vue +5 -7
  18. package/app/components/tasks/TaskSourceConnectModal.vue +2 -2
  19. package/app/composables/api/assistant.ts +5 -3
  20. package/app/composables/useArtifactBlobs.ts +2 -1
  21. package/app/composables/useModalOpen.spec.ts +51 -0
  22. package/app/composables/useModalOpen.ts +31 -0
  23. package/app/modular/panels/inspector.logic.spec.ts +3 -0
  24. package/app/modular/panels/inspector.logic.ts +5 -0
  25. package/app/modular/panels/inspector.ts +2 -0
  26. package/app/stores/assistant.spec.ts +183 -0
  27. package/app/stores/assistant.ts +83 -7
  28. package/app/types/domain.ts +4 -0
  29. package/app/types/load-state.ts +18 -0
  30. package/app/utils/catalog.ts +21 -0
  31. package/i18n/locales/de.json +14 -0
  32. package/i18n/locales/en.json +23 -0
  33. package/i18n/locales/es.json +14 -0
  34. package/i18n/locales/fr.json +14 -0
  35. package/i18n/locales/he.json +14 -0
  36. package/i18n/locales/it.json +14 -0
  37. package/i18n/locales/ja.json +14 -0
  38. package/i18n/locales/pl.json +14 -0
  39. package/i18n/locales/tr.json +14 -0
  40. package/i18n/locales/uk.json +14 -0
  41. package/package.json +2 -2
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { TESTING_CONTEXT_MAX_LENGTH } from '@cat-factory/contracts'
4
+ import type { Block } from '~/types/domain'
5
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
6
+ import { rehydratedDraft } from '~/components/panels/inspector/ServiceTestingContext.logic'
7
+ import { showOverrideField } from '~/utils/uiMode'
8
+
9
+ // Per-service (frame) TESTING CONTEXT: freeform prose about how this service is tested, which
10
+ // the engine injects verbatim into every tester prompt for it: the pipeline testers and the
11
+ // environment dry run's prober alike. It sits beside the sealed test credentials because the
12
+ // two are halves of one answer: the credentials are the material, this is what to do with it.
13
+ //
14
+ // Non-sensitive by contract: it is rendered INTO the prompt, so a real secret belongs one panel
15
+ // up, in the sealed store, and this prose refers to it by variable name. The banner says so.
16
+ //
17
+ // It is a plain block field (like the provisioning config), so it saves through the board store's
18
+ // `updateBlock` rather than a store of its own, and the draft below is what makes it an explicit
19
+ // save instead of a keystroke-per-request.
20
+ const props = defineProps<{ block: Block }>()
21
+
22
+ const board = useBoardStore()
23
+ const uiMode = useUiModeStore()
24
+ const toast = useToast()
25
+ const { t } = useI18n()
26
+
27
+ const busy = ref(false)
28
+ const draft = ref(props.block.testingContext ?? '')
29
+
30
+ // Re-hydrate from the block when the persisted value moves, but never over what the operator has
31
+ // typed (`rehydratedDraft` owns the rule and its spec states each case). The board store patches
32
+ // optimistically and ROLLS BACK on a rejected write, so a failed save arrives here looking exactly
33
+ // like a teammate's edit; taking it would erase the prose the toast is telling the operator to try
34
+ // saving again.
35
+ watch(
36
+ () => props.block.testingContext ?? '',
37
+ (incoming, previous) => {
38
+ draft.value = rehydratedDraft({ draft: draft.value, previous, incoming, saving: busy.value })
39
+ },
40
+ )
41
+
42
+ const saved = computed(() => props.block.testingContext ?? '')
43
+ // What a save would SEND, which is what the server would store: the request trims before it caps,
44
+ // so trailing whitespace is neither length spent nor a change worth a request.
45
+ const outgoing = computed(() => draft.value.trim())
46
+ const tooLong = computed(() => outgoing.value.length > TESTING_CONTEXT_MAX_LENGTH)
47
+ const dirty = computed(() => outgoing.value !== saved.value)
48
+ const canSave = computed(() => !busy.value && dirty.value && !tooLong.value)
49
+
50
+ // Standing per-service configuration, not part of the everyday delivery loop: a service is briefed
51
+ // once and every task then ships without anyone opening this. Absent, every tester prompt is
52
+ // byte-identical to one written before the field existed, which is what makes hiding it honest at
53
+ // the basic tier. `showOverrideField` (not a bare `isAdvanced`) because a service that HAS been
54
+ // briefed must show its prose to whoever opens the inspector: nothing else in the SPA surfaces
55
+ // what the testers are being told, so hiding a filled box would leave a basic-tier user unable to
56
+ // read, correct or clear it. The ROLE axis needs no separate answer: `intake` is capped at basic
57
+ // and never configures the platform, so this reaches the same people the tier bar admits.
58
+ const show = computed(() => showOverrideField(uiMode.isAdvanced, saved.value))
59
+
60
+ async function save() {
61
+ busy.value = true
62
+ try {
63
+ // `updateBlock` reports its own failure (it rolls back and toasts), so only the success
64
+ // needs saying here; announcing it unconditionally would claim a save the rollback undid.
65
+ const persisted = await board.updateBlock(props.block.id, { testingContext: outgoing.value })
66
+ if (persisted) {
67
+ toast.add({
68
+ title: t('inspector.testingContext.savedToast'),
69
+ icon: 'i-lucide-check',
70
+ color: 'success',
71
+ })
72
+ }
73
+ } finally {
74
+ busy.value = false
75
+ }
76
+ }
77
+
78
+ function revert() {
79
+ draft.value = saved.value
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <InspectorSection
85
+ v-if="show"
86
+ :title="t('inspector.testingContext.title')"
87
+ :hint="t('inspector.testingContext.sectionHint')"
88
+ data-testid="service-testing-context"
89
+ >
90
+ <!-- This text reaches the model in the prompt, so it must never hold a real secret. -->
91
+ <div
92
+ class="flex items-start gap-2 rounded-md border border-slate-700 bg-slate-800/40 px-2.5 py-2 text-[11px] leading-snug text-slate-300"
93
+ >
94
+ <UIcon name="i-lucide-info" class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
95
+ <span>{{ t('inspector.testingContext.notSecret') }}</span>
96
+ </div>
97
+
98
+ <UTextarea
99
+ v-model="draft"
100
+ :rows="8"
101
+ :placeholder="t('inspector.testingContext.placeholder')"
102
+ class="w-full"
103
+ data-testid="testing-context-input"
104
+ />
105
+
106
+ <div class="flex items-center justify-between gap-2">
107
+ <p class="text-[11px] text-slate-500" :class="{ 'text-error-400': tooLong }">
108
+ {{
109
+ t('inspector.testingContext.length', {
110
+ count: outgoing.length,
111
+ max: TESTING_CONTEXT_MAX_LENGTH,
112
+ })
113
+ }}
114
+ </p>
115
+ <div class="flex items-center gap-2">
116
+ <UButton
117
+ v-if="dirty"
118
+ color="neutral"
119
+ variant="ghost"
120
+ size="xs"
121
+ data-testid="testing-context-revert"
122
+ @click="revert"
123
+ >
124
+ {{ t('inspector.testingContext.revert') }}
125
+ </UButton>
126
+ <UButton
127
+ color="primary"
128
+ variant="soft"
129
+ size="xs"
130
+ icon="i-lucide-save"
131
+ :loading="busy"
132
+ :disabled="!canSave"
133
+ data-testid="testing-context-save"
134
+ @click="save"
135
+ >
136
+ {{ t('inspector.testingContext.save') }}
137
+ </UButton>
138
+ </div>
139
+ </div>
140
+ </InspectorSection>
141
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { DEPLOYER_AGENT_KIND } from '@cat-factory/contracts'
4
4
  import type { AgentKind, Pipeline } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
@@ -247,20 +247,20 @@ const open = computed({
247
247
  // Refresh the observability-integration state whenever the builder opens so the palette
248
248
  // knows whether to offer the post-release-health gate (it's loaded on demand, not from
249
249
  // the snapshot). Best-effort: a failure just leaves the gate hidden.
250
- watch(open, (isOpen) => {
251
- if (isOpen) releaseHealth.load().catch(() => {})
250
+ onModalOpen(open, () => {
251
+ releaseHealth.load().catch(() => {})
252
252
  // The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
253
253
  // Best-effort: the builder is fully usable without it, and a deployment that wires no
254
254
  // override store answers 503 here.
255
- if (isOpen) agentPrompts.loadIndex().catch(() => {})
255
+ agentPrompts.loadIndex().catch(() => {})
256
256
  // The workspace's per-kind output ceilings, which the per-step field shows as its inherited
257
257
  // placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
258
- if (isOpen) agentSettings.load().catch(() => {})
258
+ agentSettings.load().catch(() => {})
259
259
  // The resolved foundational-services catalog, which the binary-output picker offers from.
260
260
  // Single-flighted per workspace, so this shares the panel's load rather than adding one. A
261
261
  // failure is not swallowed into an empty picker: the store records `available: false`, and
262
262
  // the picker says the catalog is unreachable rather than "no services exist".
263
- if (isOpen) void foundational.ensureProbed()
263
+ void foundational.ensureProbed()
264
264
  })
265
265
 
266
266
  function add(kind: AgentKind) {
@@ -4,7 +4,7 @@
4
4
  // personalSubscriptions store's `pending` state, which is set when the server replies 428
5
5
  // credential_required. On submit it transparently retries the gated action and caches the
6
6
  // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
- import { computed, ref, watch } from 'vue'
7
+ import { computed, ref } from 'vue'
8
8
  import SecretInput from '~/components/common/SecretInput.vue'
9
9
 
10
10
  const { t } = useI18n()
@@ -45,8 +45,8 @@ const vendorLabel = computed(() => {
45
45
  }
46
46
  })
47
47
 
48
- watch(open, (isOpen) => {
49
- if (isOpen) password.value = ''
48
+ onModalOpen(open, () => {
49
+ password.value = ''
50
50
  })
51
51
 
52
52
  const title = computed(() => {
@@ -229,8 +229,7 @@ const request = computed(() =>
229
229
  )
230
230
  const canHunt = computed(() => request.value !== null)
231
231
 
232
- watch(open, (isOpen) => {
233
- if (!isOpen) return
232
+ onModalOpen(open, () => {
234
233
  hunt.reset()
235
234
  boardId.value = ''
236
235
  issueType.value = ''
@@ -70,13 +70,11 @@ const title = computed(() =>
70
70
  pinnedContainer.value ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
71
71
  )
72
72
 
73
- watch(open, (isOpen) => {
74
- if (isOpen) {
75
- ref_.value = ''
76
- source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
77
- resetContainer()
78
- tasks.loadTasks().catch(() => {})
79
- }
73
+ onModalOpen(open, () => {
74
+ ref_.value = ''
75
+ source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
76
+ resetContainer()
77
+ tasks.loadTasks().catch(() => {})
80
78
  })
81
79
 
82
80
  // Choosing an issue in the picker hands off to the add-task form, prefilled with the
@@ -45,8 +45,8 @@ const values = ref<Record<string, string>>({})
45
45
  const saving = ref(false)
46
46
  const togglingEnabled = ref(false)
47
47
 
48
- watch(open, (isOpen) => {
49
- if (isOpen) values.value = {}
48
+ onModalOpen(open, () => {
49
+ values.value = {}
50
50
  })
51
51
 
52
52
  const canSubmit = computed(() => {
@@ -6,9 +6,11 @@ import type { ApiContext } from './context'
6
6
  export function assistantApi({ send, ws }: ApiContext) {
7
7
  return {
8
8
  // Whether a model is wired and which actions this deployment offers. Read before the prompt
9
- // box is shown, so an unconfigured deployment says so instead of failing on submit.
10
- getAssistantCapability: (workspaceId: string) =>
11
- send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId) }),
9
+ // box is shown, so an unconfigured deployment says so instead of failing on submit. `signal`
10
+ // is what lets the store put a deadline on it: the client sets no timeout of its own, and a
11
+ // read that never settles is a modal with no answer, no failure and so no retry either.
12
+ getAssistantCapability: (workspaceId: string, signal?: AbortSignal) =>
13
+ send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId), signal }),
12
14
 
13
15
  // Run one turn. A live model call plus a board write, so it can take a couple of seconds:
14
16
  // the modal shows progress and the outcome is rendered from the returned data.
@@ -1,4 +1,5 @@
1
1
  import { reactive } from 'vue'
2
+ import type { LoadState } from '~/types/load-state'
2
3
  import { useWorkspaceStore } from '~/stores/workspace'
3
4
 
4
5
  /**
@@ -15,7 +16,7 @@ import { useWorkspaceStore } from '~/stores/workspace'
15
16
  * Both the visual-confirmation gate and the test-report window use this, so neither has to
16
17
  * own blob plumbing or depend on the other's Pinia store.
17
18
  */
18
- export type ArtifactBlobStatus = 'idle' | 'loading' | 'ready' | 'error'
19
+ export type ArtifactBlobStatus = LoadState
19
20
 
20
21
  export function useArtifactBlobs() {
21
22
  const ws = useWorkspaceStore()
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { effectScope, nextTick, ref } from 'vue'
3
+ import { onModalOpen } from './useModalOpen'
4
+
5
+ /**
6
+ * Register `onModalOpen` with `open` already at `mountedOpen`, which is what a modal's setup does.
7
+ * That first value is the whole point: the page mounts most panels only WHILE their flag is set,
8
+ * so a modal's `open` is already `true` by the time its own setup runs.
9
+ */
10
+ function register(mountedOpen: boolean) {
11
+ const open = ref(mountedOpen)
12
+ const ran = vi.fn()
13
+ const scope = effectScope()
14
+ scope.run(() => onModalOpen(open, ran))
15
+ return { open, ran, stop: () => scope.stop() }
16
+ }
17
+
18
+ describe('onModalOpen', () => {
19
+ it('runs on the render the panel MOUNTS on, when it mounts already open', () => {
20
+ // The bug this exists to make unrepresentable: a change-only `watch(open)` never fires for a
21
+ // `v-if`-mounted modal, so whatever the body seeds (a picker's default, the read that fills a
22
+ // catalog) never happens and the panel opens with an unselected control and a dead confirm.
23
+ const { ran, stop } = register(true)
24
+
25
+ expect(ran).toHaveBeenCalledTimes(1)
26
+ stop()
27
+ })
28
+
29
+ it('does not run for a panel that is mounted CLOSED', () => {
30
+ // The always-mounted half of the SPA. The same call has to be correct there, or this could not
31
+ // be the one thing every site uses and each author would be back to deciding per site.
32
+ const { ran, stop } = register(false)
33
+
34
+ expect(ran).not.toHaveBeenCalled()
35
+ stop()
36
+ })
37
+
38
+ it('runs again on each later open, and never on a close', async () => {
39
+ const { open, ran, stop } = register(false)
40
+
41
+ open.value = true
42
+ await nextTick()
43
+ open.value = false
44
+ await nextTick()
45
+ open.value = true
46
+ await nextTick()
47
+
48
+ expect(ran).toHaveBeenCalledTimes(2)
49
+ stop()
50
+ })
51
+ })
@@ -0,0 +1,31 @@
1
+ import { watch } from 'vue'
2
+ import type { WatchSource } from 'vue'
3
+
4
+ /**
5
+ * Run `fn` whenever a modal opens, INCLUDING the render it mounts on.
6
+ *
7
+ * This exists because `watch(open, (isOpen) => { if (isOpen) … })` is wrong in this SPA and reads
8
+ * as right. The page mounts most panels only WHILE their open flag is set
9
+ * (`<AssistantModal v-if="ui.assistantOpen" />`), so `open` is already `true` when the component
10
+ * sets up and a change-only watcher never fires at all: whatever the body seeds (a picker's
11
+ * default, the read that fills a catalog) never happens, and the panel opens with an unselected
12
+ * control and a confirm button that cannot be pressed. Nothing throws, which is why it survived in
13
+ * several modals at once.
14
+ *
15
+ * `{ immediate: true }` is the whole fix, and a shared helper is how it stops being per-site
16
+ * knowledge: a panel that IS always mounted passes a `false` on that first run and the body is
17
+ * skipped, so this is correct either way and nobody has to know which kind they are writing.
18
+ *
19
+ * Only for work done on the way IN. A watcher acting on the CLOSE edge (emitting `close`, revoking
20
+ * object URLs) stays a plain `watch`: it has nothing to do at mount, and running it there would
21
+ * announce a close that never happened.
22
+ */
23
+ export function onModalOpen(open: WatchSource<boolean>, fn: () => void): void {
24
+ watch(
25
+ open,
26
+ (isOpen) => {
27
+ if (isOpen) fn()
28
+ },
29
+ { immediate: true },
30
+ )
31
+ }
@@ -39,6 +39,7 @@ describe('inspector panel group', () => {
39
39
  'service-connections',
40
40
  'service-test-config',
41
41
  'service-test-secrets',
42
+ 'service-testing-context',
42
43
  'service-fragments',
43
44
  'service-release-health',
44
45
  'service-validation-checks',
@@ -58,6 +59,7 @@ describe('inspector panel group', () => {
58
59
  'container-summary',
59
60
  'service-test-config',
60
61
  'service-test-secrets',
62
+ 'service-testing-context',
61
63
  'service-fragments',
62
64
  'service-release-health',
63
65
  'service-validation-checks',
@@ -70,6 +72,7 @@ describe('inspector panel group', () => {
70
72
  'frontend-config',
71
73
  'service-test-config',
72
74
  'service-test-secrets',
75
+ 'service-testing-context',
73
76
  'service-fragments',
74
77
  'service-release-health',
75
78
  'service-validation-checks',
@@ -53,6 +53,7 @@ export const INSPECTOR_PANEL_IDS = [
53
53
  'service-connections',
54
54
  'service-test-config',
55
55
  'service-test-secrets',
56
+ 'service-testing-context',
56
57
  'service-fragments',
57
58
  'service-release-health',
58
59
  'service-validation-checks',
@@ -146,6 +147,10 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
146
147
  { id: 'service-connections', order: 130, when: (b) => isFrame(b) && b.type === 'service' },
147
148
  { id: 'service-test-config', order: 140, when: isDeployableFrame },
148
149
  { id: 'service-test-secrets', order: 150, when: isDeployableFrame },
150
+ // Immediately after the credentials: the prose that says what to DO with them, and the other
151
+ // half of what a tester is handed about this service. Same gate for the same reason (a doc
152
+ // repo runs no tester at all).
153
+ { id: 'service-testing-context', order: 155, when: isDeployableFrame },
149
154
  { id: 'service-fragments', order: 160, when: isFrame },
150
155
  { id: 'service-release-health', order: 170, when: isDeployableFrame },
151
156
  // Pre-PR validation checks: the commands the harness runs before opening this service's PRs.
@@ -28,6 +28,7 @@ import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
28
28
  import ServiceConnections from '~/components/panels/inspector/ServiceConnections.vue'
29
29
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
30
30
  import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
31
+ import ServiceTestingContext from '~/components/panels/inspector/ServiceTestingContext.vue'
31
32
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
32
33
  import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
33
34
  import ServiceValidationConfig from '~/components/panels/inspector/ServiceValidationConfig.vue'
@@ -83,6 +84,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
83
84
  'service-connections': ServiceConnections,
84
85
  'service-test-config': ServiceTestConfig,
85
86
  'service-test-secrets': ServiceTestSecrets,
87
+ 'service-testing-context': ServiceTestingContext,
86
88
  'service-fragments': ServiceFragments,
87
89
  'service-release-health': ServiceReleaseHealthConfig,
88
90
  'service-validation-checks': ServiceValidationConfig,
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { useAssistantStore } from '~/stores/assistant'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import { ApiError } from '~/composables/api/errors'
5
+ import type { AssistantCapability } from '~/types/domain'
6
+
7
+ // What the modal reads to decide whether it may offer the prompt box, and whether that box may be
8
+ // submitted. The capability's own ANSWER is two independent bits (is a model wired, and is there
9
+ // anything for it to do); on their own they cannot say whether anyone has asked yet, which is a
10
+ // third fact the modal has to render differently from both.
11
+
12
+ /**
13
+ * Stub `useApi` ONCE, behind a handler the test can swap. The store resolves `useApi()` at setup,
14
+ * so re-stubbing after `useAssistantStore()` would leave it holding the first stub for ever.
15
+ *
16
+ * The handler is given the abort signal the store attaches its deadline to, so a test can assert
17
+ * on what a timed-out read does to the request as well as to the state.
18
+ */
19
+ function stubApi(): {
20
+ store: ReturnType<typeof useAssistantStore>
21
+ serve: (fn: (signal: AbortSignal) => Promise<AssistantCapability>) => void
22
+ } {
23
+ let handler: (signal: AbortSignal) => Promise<AssistantCapability> = () =>
24
+ Promise.resolve({ available: true, actions: ['declare-service-dependency'] })
25
+ vi.stubGlobal('useApi', () => ({
26
+ getAssistantCapability: (_ws: string, signal: AbortSignal) => handler(signal),
27
+ }))
28
+ return {
29
+ store: useAssistantStore(),
30
+ serve: (fn) => {
31
+ handler = fn
32
+ },
33
+ }
34
+ }
35
+
36
+ const failing = (status = 503) =>
37
+ Promise.reject(new ApiError(status, { error: { code: 'unavailable', message: 'nope' } }))
38
+
39
+ describe('assistant store: the capability read', () => {
40
+ beforeEach(() => {
41
+ useWorkspaceStore().workspaceId = 'ws1'
42
+ })
43
+
44
+ it('starts idle, which is not the same fact as unavailable', () => {
45
+ const { store } = stubApi()
46
+
47
+ expect(store.capabilityRead).toBe('idle')
48
+ expect(store.capability).toBeNull()
49
+ expect(store.available).toBe(false)
50
+ })
51
+
52
+ it('holds `loading` while the read is in flight', async () => {
53
+ const { store, serve } = stubApi()
54
+ let answer!: (capability: AssistantCapability) => void
55
+ serve(() => new Promise((resolve) => (answer = resolve)))
56
+
57
+ const inFlight = store.loadCapability()
58
+ expect(store.capabilityRead).toBe('loading')
59
+
60
+ answer({ available: true, actions: ['declare-service-dependency'] })
61
+ await inFlight
62
+
63
+ expect(store.capabilityRead).toBe('ready')
64
+ expect(store.actions).toEqual(['declare-service-dependency'])
65
+ })
66
+
67
+ it('KEEPS the answer it holds while a re-read is in flight', async () => {
68
+ // Re-opening the modal re-reads. Clearing the answer for the duration would replace the box
69
+ // with a waiting state on every open, for a fact the store can already state.
70
+ const { store, serve } = stubApi()
71
+ await store.loadCapability()
72
+
73
+ serve(() => new Promise(() => {}))
74
+ void store.loadCapability()
75
+
76
+ expect(store.capabilityRead).toBe('loading')
77
+ expect(store.available).toBe(true)
78
+ expect(store.actions).toEqual(['declare-service-dependency'])
79
+ })
80
+
81
+ it('records a read that answered "no model" as read, so it can be told from an outage', async () => {
82
+ const { store, serve } = stubApi()
83
+ serve(() => Promise.resolve({ available: false, actions: [] }))
84
+
85
+ await store.loadCapability()
86
+
87
+ expect(store.capabilityRead).toBe('ready')
88
+ expect(store.available).toBe(false)
89
+ })
90
+
91
+ it('marks a FAILED read as failed and does not throw', async () => {
92
+ // The failure is REPORTED by the panel this state puts on screen, which is where the retry
93
+ // lives too. Throwing it as well would have the modal toast a second, non-dismissing copy of
94
+ // the same sentence over that panel, once per retry.
95
+ const { store, serve } = stubApi()
96
+ serve(() => failing())
97
+
98
+ await expect(store.loadCapability()).resolves.toBeUndefined()
99
+
100
+ expect(store.capabilityRead).toBe('error')
101
+ expect(store.capability).toBeNull()
102
+ expect(store.available).toBe(false)
103
+ })
104
+
105
+ it('drops a previous answer when a later read fails', async () => {
106
+ // A retry that fails must not leave the box open on the strength of the read before it: the
107
+ // deployment's model may have gone away with whatever took the endpoint down.
108
+ const { store, serve } = stubApi()
109
+ serve(() => Promise.resolve({ available: true, actions: ['add-service-from-repo'] }))
110
+ await store.loadCapability()
111
+
112
+ serve(() => failing(500))
113
+ await store.loadCapability()
114
+
115
+ expect(store.capability).toBeNull()
116
+ expect(store.actions).toEqual([])
117
+ })
118
+
119
+ it('recovers on a retry that answers', async () => {
120
+ const { store, serve } = stubApi()
121
+ serve(() => failing(502))
122
+ await store.loadCapability()
123
+
124
+ serve(() => Promise.resolve({ available: true, actions: ['create-task-from-issue'] }))
125
+ await store.loadCapability()
126
+
127
+ expect(store.capabilityRead).toBe('ready')
128
+ expect(store.available).toBe(true)
129
+ })
130
+
131
+ it('settles overlapping reads in the order they STARTED, not the order they answer', async () => {
132
+ // A slow success that lands after the fast failure which superseded it would otherwise
133
+ // re-offer the box on an answer older than the failure that replaced it.
134
+ const { store, serve } = stubApi()
135
+ let answerSlow!: (capability: AssistantCapability) => void
136
+ serve(() => new Promise((resolve) => (answerSlow = resolve)))
137
+ const slow = store.loadCapability()
138
+
139
+ serve(() => failing())
140
+ await store.loadCapability()
141
+ expect(store.capabilityRead).toBe('error')
142
+
143
+ answerSlow({ available: true, actions: ['declare-service-dependency'] })
144
+ await slow
145
+
146
+ expect(store.capabilityRead).toBe('error')
147
+ expect(store.capability).toBeNull()
148
+ })
149
+ })
150
+
151
+ describe('assistant store: the capability read deadline', () => {
152
+ beforeEach(() => {
153
+ useWorkspaceStore().workspaceId = 'ws1'
154
+ vi.useFakeTimers()
155
+ })
156
+ afterEach(() => {
157
+ vi.useRealTimers()
158
+ })
159
+
160
+ it('fails a read that never settles, and ABORTS the request it gave up on', async () => {
161
+ // The shared client sets no timeout, so a connection accepted and never answered would leave
162
+ // the modal waiting for ever: no answer, no failure, and so no retry either, since the retry
163
+ // is what a failed read puts on screen.
164
+ const { store, serve } = stubApi()
165
+ let aborted = false
166
+ serve(
167
+ (signal) =>
168
+ new Promise((_resolve, reject) => {
169
+ signal.addEventListener('abort', () => {
170
+ aborted = true
171
+ reject(new Error('aborted'))
172
+ })
173
+ }),
174
+ )
175
+
176
+ const read = store.loadCapability()
177
+ await vi.advanceTimersByTimeAsync(10_000)
178
+ await read
179
+
180
+ expect(aborted).toBe(true)
181
+ expect(store.capabilityRead).toBe('error')
182
+ })
183
+ })