carthagent 1.1.0-preview.12 → 1.1.0-preview.13

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 (46) hide show
  1. package/README.md +69 -13
  2. package/docs/native-manual-testing.md +27 -5
  3. package/docs/opencode-owned-runtime.md +3 -3
  4. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  5. package/docs/release-notes-preview.13.md +68 -0
  6. package/docs/source-preview.md +120 -0
  7. package/lib/delivery-review.mjs +11 -1
  8. package/lib/opencode/runtime-release.json +12 -12
  9. package/lib/opencode/runtime.mjs +5 -4
  10. package/lib/providers/experiential.mjs +2 -2
  11. package/package.json +1 -1
  12. package/plugins/carthagent/src/daily-source-rpc.ts +2 -2
  13. package/plugins/carthagent/src/server/daily-source-rpc.ts +18 -4
  14. package/plugins/carthagent/src/server/delivery.ts +10 -6
  15. package/plugins/carthagent/src/server/providers.ts +1 -1
  16. package/plugins/carthagent/src/server/ship-guidance.ts +4 -2
  17. package/plugins/carthagent/src/tui.tsx +17 -13
  18. package/plugins/carthagent/src/ui/daily-quests.tsx +5 -2
  19. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  20. package/plugins/carthagent/src/ui/native-review.d.mts +2 -0
  21. package/plugins/carthagent/src/ui/native-review.mjs +7 -2
  22. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  23. package/plugins/carthagent/src/ui/voyages-navigation.tsx +11 -2
  24. package/plugins/carthagent/src/ui/voyages.tsx +4 -2
  25. package/plugins/carthagent/tests/voyages-navigation.test.tsx +20 -9
  26. package/script/ctg/build-owned-runtime.sh +3 -3
  27. package/tests/delivery-review.test.mjs +11 -0
  28. package/tests/native-daily-source.test.mjs +27 -2
  29. package/tests/native-manual-projects.test.mjs +3 -3
  30. package/tests/native-model-command.test.mjs +67 -0
  31. package/tests/native-review-consumer.test.mjs +19 -0
  32. package/tests/native-select-mouse.test.mjs +23 -0
  33. package/tests/native-ship-plan.test.mjs +13 -3
  34. package/tests/opencode-delivery.test.mjs +16 -0
  35. package/tests/owned-runtime-publish-workflow.test.mjs +6 -6
  36. package/tests/runtime-development-trust.test.mjs +5 -5
  37. package/tests/server.test.mjs +3 -1
  38. package/tests/ship-telemetry.test.mjs +1 -1
  39. package/tests/source-preview.test.mjs +253 -0
  40. package/tests/update.test.mjs +6 -5
  41. package/tools/benchmark/planning-startup.test.mjs +1 -1
  42. package/tools/test-native-delivery-host.mjs +2 -2
  43. package/tools/test-native-host.mjs +4 -2
  44. package/tools/test-native-public-repo.mjs +1 -1
  45. package/tools/test-native-ship-walkthrough.mjs +1 -1
  46. package/tools/test-native-swe-host.mjs +4 -4
@@ -13,8 +13,8 @@ export function nativeReviewMode(session, config, explicitMode) {
13
13
  }
14
14
 
15
15
  /** Correlate host executions and admit one current-run kickoff per session. */
16
- /** @param {{ mode: (sessionID: string) => string, diff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, offer: (value: { sessionID: string; runKey: string; writes: number; start: () => Promise<boolean> }) => Promise<unknown>, kickoff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, isLoopActive?: (sessionID: string) => boolean, setLoopActive?: (sessionID: string, active: boolean) => void, signal?: AbortSignal }} options */
17
- export function createNativeReviewConsumer({ mode, diff, offer, kickoff, isLoopActive = () => false, setLoopActive = () => {}, signal }) {
16
+ /** @param {{ mode: (sessionID: string) => string, diff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, offer: (value: { sessionID: string; runKey: string; writes: number; start: () => Promise<boolean> }) => Promise<unknown>, eligible?: (sessionID: string, signal?: AbortSignal) => Promise<boolean>, kickoff: (sessionID: string, signal?: AbortSignal) => Promise<unknown>, isLoopActive?: (sessionID: string) => boolean, setLoopActive?: (sessionID: string, active: boolean) => void, signal?: AbortSignal }} options */
17
+ export function createNativeReviewConsumer({ mode, diff, offer, kickoff, eligible = async () => true, isLoopActive = () => false, setLoopActive = () => {}, signal }) {
18
18
  const runs = new Map();
19
19
  const admitted = new Set();
20
20
  const pending = new Set();
@@ -52,6 +52,11 @@ export function createNativeReviewConsumer({ mode, diff, offer, kickoff, isLoopA
52
52
  const runKey = `${sessionID}@${run.id}`;
53
53
  const currentMode = mode(sessionID);
54
54
  if (!(currentMode === "ask" || currentMode === "yes") || writes <= 0 || admitted.has(runKey) || (currentMode === "yes" && loopSessions.has(sessionID))) return;
55
+ // A formally blocked or unfinished delivery is not a reviewable run.
56
+ let reviewable;
57
+ try { reviewable = await eligible(sessionID, signal); }
58
+ catch { return; }
59
+ if (!reviewable || disposed || signal?.aborted || runs.get(sessionID) !== run || admitted.has(runKey)) return;
55
60
  admitted.add(runKey);
56
61
 
57
62
  const start = async () => {
@@ -0,0 +1,17 @@
1
+ import type { MouseEvent, SelectRenderable } from "@opentui/core";
2
+
3
+ // SelectRenderable only handles keys; map a left click to the fully drawn row under the pointer and activate it.
4
+ // Bind it to mouse-up: an action that opens a dialog on mouse-down is closed by that dialog's backdrop on release.
5
+ export function clickSelect(node: SelectRenderable | undefined, event: MouseEvent) {
6
+ if (!node || event.button !== 0) return;
7
+ const { scrollOffset, linesPerItem, maxVisibleItems } = node as unknown as {
8
+ scrollOffset: number;
9
+ linesPerItem: number;
10
+ maxVisibleItems: number;
11
+ };
12
+ const row = Math.floor((event.y - node.screenY) / Math.max(1, linesPerItem));
13
+ const index = scrollOffset + row;
14
+ if (row < 0 || row >= maxVisibleItems || index >= node.options.length) return;
15
+ node.setSelectedIndex(index);
16
+ node.selectCurrent();
17
+ }
@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
3
3
  import { CliRenderEvents, type ScrollBoxRenderable, type SelectRenderable } from "@opentui/core";
4
4
  import { useRenderer } from "@opentui/solid";
5
5
  import { usePlugin } from "@opencode/plugin/tui";
6
+ import { clickSelect } from "./select-mouse.js";
6
7
  import { dailyQuestsClient, voyagesClient } from "./client.js";
7
8
  import { nativeNavigation, type NavigationInput } from "./native-navigation.js";
8
9
  import { createVoyagesNavigationState } from "./voyages-navigation-state.mjs";
@@ -27,6 +28,7 @@ export function VoyagesNavigation(props: NavigationInput) {
27
28
  let scrollTop = 0;
28
29
  let restoreTop: number | undefined;
29
30
  const [viewportHeight, setViewportHeight] = createSignal(3);
31
+ const [railWidth, setRailWidth] = createSignal(0);
30
32
  createEffect(() => {
31
33
  if (!props.active) { scrollTop = scroll?.scrollTop ?? scrollTop; return; }
32
34
  if (!view().busy) { restoreTop = scrollTop; renderer.requestRender(); }
@@ -46,6 +48,7 @@ export function VoyagesNavigation(props: NavigationInput) {
46
48
  const frame = () => {
47
49
  if (!scroll || scroll.isDestroyed || !props.active) return;
48
50
  setViewportHeight(Math.max(1, scroll.viewport.height));
51
+ if (actions && !actions.isDestroyed) setRailWidth(actions.width);
49
52
  if (restoreTop === undefined || view().busy) return;
50
53
  scroll.scrollTop = restoreTop;
51
54
  restoreTop = undefined;
@@ -69,7 +72,13 @@ export function VoyagesNavigation(props: NavigationInput) {
69
72
  if (nativeNavigation(context)?.current() !== "voyages") return;
70
73
  context.ui.router.navigate({ type: "plugin", name, data: { ...(props.sessionID ? { sessionID: props.sessionID } : {}), ...extra } });
71
74
  };
72
- const options = () => [
75
+ // Select draws raw text; fit names and descriptions to the rail.
76
+ const fit = (text: string) => {
77
+ const room = railWidth() - 3;
78
+ return room > 0 && text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text;
79
+ };
80
+ const options = () => entries().map((item) => ({ ...item, name: fit(item.name), description: fit(item.description) }));
81
+ const entries = () => [
73
82
  { name: props.compact ? "Q" : "Daily coding quests", description: "Assignments & assessments", value: "daily" },
74
83
  ...(view().daily?.items.map((quest, index) => ({ name: props.compact ? String(index + 1) : quest.title, description: quest.status, value: `quest:${quest.id}` })) ?? []),
75
84
  { name: props.compact ? "P" : "Profile & wallet", description: "Authoritative profile", value: "profile" },
@@ -105,7 +114,7 @@ export function VoyagesNavigation(props: NavigationInput) {
105
114
  <Show when={view().error}><text flexShrink={0} fg={context.theme.text.feedback.warning.base}> {view().error}. Sign in or refresh.</text></Show>
106
115
  <Show when={view().daily}><text flexShrink={0} fg={context.theme.text.muted}> UTC {view().daily?.day}</text></Show>
107
116
  </Show>
108
- <select ref={actions} minHeight={1} height={Math.min(18, viewportHeight(), options().length * (props.compact ? 1 : 2))} flexShrink={0} showScrollIndicator showDescription={!props.compact} options={options()} selectedIndex={Math.max(0, options().findIndex((item) => item.value === selection()))} focused={focused()} onChange={(_index, item) => {
117
+ <select ref={actions} onMouseUp={(event) => { if (focused()) clickSelect(actions, event); }} minHeight={1} height={Math.min(18, viewportHeight(), options().length * (props.compact ? 1 : 2))} flexShrink={0} showScrollIndicator showDescription={!props.compact} options={options()} selectedIndex={Math.max(0, options().findIndex((item) => item.value === selection()))} focused={focused()} onChange={(_index, item) => {
109
118
  if (!focused() || view().busy || !item) return;
110
119
  setSelection(item.value);
111
120
  if (actions) scroll?.scrollChildIntoView(actions.id);
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { Show, createSignal, onCleanup, onMount } from "solid-js";
3
- import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import type { ScrollBoxRenderable, SelectRenderable } from "@opentui/core";
4
+ import { clickSelect } from "./select-mouse.js";
4
5
  import { usePlugin } from "@opencode/plugin/tui";
5
6
  import { voyagesClient, type VoyagesSnapshot, type VoyagesProfile, type VoyagesItem, type VoyagesQuote } from "./client.js";
6
7
  import { canEquip, createVoyagesState, handleVoyagesEvent, voyagesCatalogLabel } from "./voyages-state.mjs";
@@ -9,6 +10,7 @@ type ViewState = { snapshot?: VoyagesSnapshot; busy: boolean; error: string; not
9
10
 
10
11
  /** All mutations go through the lifetime-guarded controller, including dialogs. */
11
12
  export function VoyagesPage(props: { sessionID?: string; section?: "profile" | "equipment" }) {
13
+ let list: SelectRenderable | undefined;
12
14
  const context = usePlugin();
13
15
  const location = () => (props.sessionID ? context.data.session.get(props.sessionID)?.location : undefined) ?? context.location ?? context.data.location.default();
14
16
  const [view, setView] = createSignal<ViewState>({ busy: false, error: "", notice: "" });
@@ -122,6 +124,6 @@ export function VoyagesPage(props: { sessionID?: string; section?: "profile" | "
122
124
  <text flexShrink={0} fg={context.theme.text.faint}>Choose Back to return. Cancel in dialogs never saves or purchases.</text>
123
125
  </scrollbox>
124
126
  <text flexShrink={0} fg={context.theme.text.faint}>↑/↓ Enter · PgUp/Dn choices · Ctrl+U/D profile</text>
125
- <select flexBasis={0} flexGrow={1} flexShrink={0} minHeight={3} showScrollIndicator keyBindings={[{ name: "pageup", action: "move-up-fast" }, { name: "pagedown", action: "move-down-fast" }]} options={options()} focused={context.keymap.mode.current() === "base"} onSelect={(_index, option) => select(option?.value)} />
127
+ <select flexBasis={0} flexGrow={1} flexShrink={0} minHeight={3} ref={(node) => { list = node; }} onMouseUp={(event) => clickSelect(list, event)} showScrollIndicator keyBindings={[{ name: "pageup", action: "move-up-fast" }, { name: "pagedown", action: "move-down-fast" }]} options={options()} focused={context.keymap.mode.current() === "base"} onSelect={(_index, option) => select(option?.value)} />
126
128
  </box>;
127
129
  }
@@ -201,28 +201,39 @@ test("short terminal keyboard navigation keeps the highlighted action on screen"
201
201
  } finally { f.app.renderer.destroy(); }
202
202
  });
203
203
 
204
- test("mouse focus enters navigation only from base mode and never acts through an open modal", async () => {
204
+ test("mouse press focuses the rail and release opens the clicked row without acting through a modal", async () => {
205
205
  const f = await fixture();
206
206
  try {
207
207
  const row = f.app.captureCharFrame().split("\n").findIndex((line) => line.includes("Daily coding quests"));
208
208
  expect(row).toBeGreaterThanOrEqual(0);
209
- f.setMode("modal");
210
- await f.app.renderOnce();
211
- await f.app.mockMouse.click(5, row);
212
- await f.app.renderOnce();
213
- expect(f.mode()).toBe("modal");
214
- expect(f.routes).toHaveLength(0);
209
+ for (const mode of ["modal", "palette"]) {
210
+ f.setMode(mode);
211
+ await f.app.renderOnce();
212
+ await f.app.mockMouse.click(5, row);
213
+ await f.app.renderOnce();
214
+ expect(f.mode()).toBe(mode);
215
+ expect(f.routes).toHaveLength(0);
216
+ }
215
217
  f.setMode("base");
216
218
  f.app.renderer.clearSelection();
217
219
  await f.app.renderOnce();
218
- await f.app.mockMouse.click(5, row);
220
+ await f.app.mockMouse.pressDown(5, row);
219
221
  await f.app.renderOnce();
220
222
  expect(f.mode()).toBe("navigation");
221
223
  expect(f.app.renderer.currentFocusedRenderable).toBeInstanceOf(SelectRenderable);
222
224
  expect(f.routes).toHaveLength(0);
223
- f.app.mockInput.pressEnter();
225
+ await f.app.mockMouse.release(5, row);
224
226
  await f.app.renderOnce();
225
227
  expect(f.routes).toEqual([{ type: "plugin", name: "daily-quests", data: { sessionID: "coding-session" } }]);
228
+ expect(f.mode()).toBe("base");
229
+ const questRow = f.app.captureCharFrame().split("\n").findIndex((line) => line.includes("Review a change"));
230
+ expect(questRow).toBeGreaterThanOrEqual(0);
231
+ await f.app.mockMouse.click(5, questRow);
232
+ await f.app.renderOnce();
233
+ expect(f.routes).toEqual([
234
+ { type: "plugin", name: "daily-quests", data: { sessionID: "coding-session" } },
235
+ { type: "plugin", name: "daily-quests", data: { sessionID: "coding-session", questID: "quest-two" } },
236
+ ]);
226
237
  } finally { f.app.renderer.destroy(); }
227
238
  });
228
239
 
@@ -6,7 +6,7 @@ set -euo pipefail
6
6
  ROOT="$(git rev-parse --show-toplevel)"
7
7
  EXPECTED_SOURCE="843a6c514e606b436a7fe713422ace0a93ad3d61"
8
8
  EXPECTED_LOCK="9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d"
9
- EXPECTED_PATCH="1f2309bb3a31dfb8e8a0827a6a1144a1f76a005abff58f6f48857639ec02478a"
9
+ EXPECTED_PATCH="ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975"
10
10
  TARGET="${1:-opencode-linux-x64}"
11
11
  HOST_PLATFORM="$(node -p 'process.platform')"
12
12
  HOST_ARCH="$(node -p 'process.arch')"
@@ -34,7 +34,7 @@ if ! git diff --cached --quiet HEAD --; then
34
34
  exit 1
35
35
  fi
36
36
  expected_inputs=(
37
- "docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch"
37
+ "docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch"
38
38
  "script/ctg/build-owned-runtime.sh"
39
39
  )
40
40
  actual_status="$(git status --porcelain --untracked-files=all)"
@@ -86,7 +86,7 @@ if [[ "$(bun --version)" != "1.4.2" ]]; then
86
86
  exit 1
87
87
  fi
88
88
 
89
- patch_file="$ROOT/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch"
89
+ patch_file="$ROOT/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch"
90
90
  actual_patch="$("${HASH_COMMAND[@]}" "$patch_file" | awk '{print $1}')"
91
91
  if [[ "$actual_patch" != "$EXPECTED_PATCH" ]]; then
92
92
  echo "Patch checksum mismatch: $actual_patch" >&2
@@ -137,3 +137,14 @@ test('large diffs retain full receipt instead of silently using output tail', as
137
137
  assert.ok(readFileSync(capture.path, 'utf8').length > 12000);
138
138
  assert.match(capture.diff, /diff --git/);
139
139
  });
140
+
141
+ test('review accepts assertion aliases and names missing requirements', async t => {
142
+ const { cwd, state, params, dir } = fixture(t);
143
+ const capture = await captureDeliveryReview(state, cwd, dir);
144
+ assert.throws(() => recordDeliveryReview(state, cwd, { ...params(capture.id), coverage: [{ requirement: 'Other', assertions: 'x' }] }), /Missing: \["Correct amount"\]/);
145
+ recordDeliveryReview(state, cwd, { ...params(capture.id), coverage: [{ requirement: 'Correct amount', assertion: 'app amount equals 2' }] });
146
+ assert.equal(state.reviewEvidence.coverage[0].assertions, 'app amount equals 2');
147
+ const again = await captureDeliveryReview(state, cwd, dir);
148
+ recordDeliveryReview(state, cwd, { ...params(again.id), coverage: [{ requirement: 'Correct amount', assertions: ['amount is 2', 'new result is 2'] }] });
149
+ assert.equal(state.reviewEvidence.coverage[0].assertions, 'amount is 2; new result is 2');
150
+ });
@@ -87,6 +87,29 @@ test('local source uses actual receipt identity, actual goal, no synthetic befor
87
87
  assert.equal((await f.read()).source.checks[2].after, 'passed');
88
88
  });
89
89
 
90
+ test('explicit finish change summary, first-run failed baseline and long output tails are projected', async t => {
91
+ const f = fixture(t, [check('unit'), check('fresh')]);
92
+ f.state.evidence.unit = f.receipt('unit', { outputTail: `${'x'.repeat(900)}\nline two\n${'y'.repeat(300)}\nok 17 - final` });
93
+ f.state.evidence.fresh = f.receipt('fresh');
94
+ f.state.baselines = {
95
+ unit: { passed: false, code: 1, checkDigest: f.receipt('unit').checkDigest, fingerprint: '1'.repeat(64) },
96
+ fresh: { passed: false, code: 1, checkDigest: f.receipt('fresh').checkDigest, fingerprint: f.current },
97
+ };
98
+ f.state.handoff = { change: 'Implemented parseCsv as a state machine', review: 'r', fingerprint: f.current };
99
+ f.save();
100
+ const { source } = await f.read();
101
+ assert.equal(source.change, 'Implemented parseCsv as a state machine');
102
+ assert.equal(source.checks[0].before, 'failed');
103
+ // A failure recorded on the current source is not a before-change result.
104
+ assert.equal(source.checks[1].before, 'not_run');
105
+ assert.match(source.checks[0].result, /^…\n/);
106
+ assert.match(source.checks[0].result, /ok 17 - final$/);
107
+ assert.equal(source.truncated, true);
108
+ assert.ok(Buffer.byteLength(source.checks[0].result) <= 1024);
109
+ f.state.handoff.fingerprint = '2'.repeat(64); f.save();
110
+ assert.equal((await f.read()).source.change, '');
111
+ });
112
+
90
113
  test('stale, mismatched or contradictory passing receipts never become current passing evidence', async t => {
91
114
  const f = fixture(t);
92
115
  const invalidations = [
@@ -179,7 +202,9 @@ test('selection is bounded, UTF-8 safe and explicitly reports omitted claims/ite
179
202
  assert.equal(source.truncated, true);
180
203
  assert.equal(source.goal, '');
181
204
  assert.equal(source.checks[0].result, '界'.repeat(341));
182
- assert.equal(source.checks[1].result, '', 'omit entire claims rather than changing their meaning');
205
+ // Receipts store an output tail; an oversized tail keeps its end, never a partial start.
206
+ assert.equal(source.checks[1].result.startsWith('…\n'), true);
207
+ assert.ok(Buffer.byteLength(source.checks[1].result, 'utf8') <= 1024);
183
208
  assert.equal(source.findings[0].issue, '');
184
209
  assert.equal(source.findings[0].evidence, '');
185
210
  assert.equal(JSON.stringify(source).includes('\ufffd'), false);
@@ -419,7 +444,7 @@ test('pinned Effect RPC conversion accepts local source, null, input and safe ty
419
444
  for (const value of [await f.read(), { source: null }]) assert.deepEqual(decode(method.output, value), value);
420
445
  assert.deepEqual(decode(method.errors.dailySourceError, { code: 'not_owned' }), { code: 'not_owned' });
421
446
  assert.throws(() => decode(method.input, {}));
422
- const forged = await f.read(); forged.source.checks[0].before = 'failed';
447
+ const forged = await f.read(); forged.source.checks[0].before = 'passed';
423
448
  assert.throws(() => decode(method.output, forged));
424
449
  const wrongRevision = await f.read(); wrongRevision.source.revision = 3;
425
450
  assert.throws(() => decode(method.output, wrongRevision));
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { createHash } from 'node:crypto';
8
- import { CTG_RUNTIME_SOURCE, CTG_RUNTIME_PATCH_SHA256, CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, CTG_RUNTIME_LOCK_SHA256 } from '../lib/opencode/runtime.mjs';
8
+ import { CTG_RUNTIME_SOURCE, CTG_RUNTIME_PATCH_SHA256, CTG_RUNTIME_LOCK_SHA256 } from '../lib/opencode/runtime.mjs';
9
9
  import { createManualProjects } from '../tools/native-manual-projects.mjs';
10
10
  import { readManualRuntimeManifest } from '../tools/manual-runtime-manifest.mjs';
11
11
  import { readRuntimeReleaseManifest } from '../lib/opencode/runtime.mjs';
@@ -71,7 +71,7 @@ test('manual kit preparation refuses existing directories and removes only its o
71
71
  const runtimeManifest = join(temp, 'runtime-release.json');
72
72
  const manifest = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
73
73
  manifest.releaseVersion = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
74
- Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
74
+ Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
75
75
  const target = `${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`;
76
76
  manifest.assets = { [target]: manifest.assets[target] };
77
77
  const bytes = JSON.stringify(manifest);
@@ -145,7 +145,7 @@ test('single-platform manual candidates bind current recipe bytes without weaken
145
145
  const runtimeManifest = join(temp, 'candidate.json');
146
146
  const manifest = JSON.parse(await readFile(new URL('../lib/opencode/runtime-release.json', import.meta.url), 'utf8'));
147
147
  manifest.releaseVersion = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
148
- Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
148
+ Object.assign(manifest, { sourceRevision: CTG_RUNTIME_SOURCE, patchSha256: CTG_RUNTIME_PATCH_SHA256, lockSha256: CTG_RUNTIME_LOCK_SHA256 });
149
149
  const asset = manifest.assets['linux-x64'];
150
150
  manifest.assets = { 'linux-x64': asset };
151
151
  const save = async () => {
@@ -0,0 +1,67 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const { createJiti } = require('jiti');
8
+ const jiti = createJiti(import.meta.url);
9
+ const { runModelCommand } = await jiti.import('../plugins/carthagent/src/ui/model-command.ts');
10
+
11
+ const ship = { providerID: 'experiential-labs', modelID: 'deepseek-v4.1-flash', id: 'deepseek-v4.1-flash', name: 'DeepSeek V4.1 Flash' };
12
+ const other = { providerID: 'demo', modelID: 'alpha', id: 'alpha', name: 'Alpha' };
13
+
14
+ function host(overrides = {}) {
15
+ const calls = { native: [], switched: [], selects: [], toasts: [], syncs: 0 };
16
+ return {
17
+ calls,
18
+ value: {
19
+ sync: async () => { calls.syncs++; },
20
+ list: () => [ship, other],
21
+ select: async (options) => { calls.selects.push(options.map((option) => option.title)); return options[0]?.value; },
22
+ switchModel: async (sessionID, model) => { calls.switched.push({ sessionID, model }); },
23
+ openNativePicker: (query) => { calls.native.push(query); },
24
+ providerName: (id) => (id === 'experiential-labs' ? 'Carthagent Ship' : id),
25
+ toast: (message, variant) => { calls.toasts.push({ message, variant }); },
26
+ ...overrides,
27
+ },
28
+ };
29
+ }
30
+
31
+ test('pre-session /model opens the native configured-model picker with its argument and never switches a session', async () => {
32
+ const bare = host();
33
+ await runModelCommand(bare.value, { arg: '' });
34
+ assert.deepEqual(bare.calls.native, ['']);
35
+ const named = host();
36
+ await runModelCommand(named.value, { arg: 'deepseek' });
37
+ assert.deepEqual(named.calls.native, ['deepseek']);
38
+ for (const run of [bare, named]) {
39
+ assert.deepEqual(run.calls.switched, []);
40
+ assert.deepEqual(run.calls.selects, []);
41
+ assert.equal(run.calls.toasts.some((toast) => /Open or create a session/.test(toast.message)), false);
42
+ }
43
+ });
44
+
45
+ test('existing-session /model keeps filtering by argument and commits the pick to that session only', async () => {
46
+ const run = host();
47
+ await runModelCommand(run.value, { sessionID: 'ses_existing', arg: 'deepseek' });
48
+ assert.equal(run.calls.syncs, 1);
49
+ assert.deepEqual(run.calls.selects, [['Carthagent Ship/deepseek-v4.1-flash']]);
50
+ assert.deepEqual(run.calls.switched, [{ sessionID: 'ses_existing', model: { providerID: 'experiential-labs', id: 'deepseek-v4.1-flash' } }]);
51
+ assert.deepEqual(run.calls.native, []);
52
+ assert.deepEqual(run.calls.toasts, [{ message: 'Selected Carthagent Ship/deepseek-v4.1-flash.', variant: 'success' }]);
53
+ });
54
+
55
+ test('cancelling the existing-session picker changes nothing', async () => {
56
+ const run = host({ select: async () => undefined });
57
+ await runModelCommand(run.value, { sessionID: 'ses_existing', arg: '' });
58
+ assert.deepEqual(run.calls.switched, []);
59
+ assert.deepEqual(run.calls.toasts, []);
60
+ });
61
+
62
+ test('plugin /model dispatch routes through the shared command and the native model.list command', async () => {
63
+ const tui = await readFile(new URL('../plugins/carthagent/src/tui.tsx', import.meta.url), 'utf8');
64
+ assert.match(tui, /runModelCommand\(/);
65
+ assert.match(tui, /keymap\.dispatch\("model\.list", query\)/);
66
+ assert.doesNotMatch(tui, /Open or create a session before selecting its model/);
67
+ });
@@ -58,6 +58,25 @@ test('ask confirmation is current-run-bound and one-shot; a later run invalidate
58
58
  assert.deepEqual(kickoffs, ['session-a']);
59
59
  });
60
60
 
61
+ test('a run whose formal delivery is not reviewable is never offered or kicked off', async () => {
62
+ for (const mode of ['ask', 'yes']) {
63
+ const offers = [], kickoffs = [], asked = [];
64
+ const consumer = createNativeReviewConsumer({
65
+ mode: () => mode, diff: makeDiff(), offer: async (offer) => offers.push(offer),
66
+ eligible: async (sessionID) => { asked.push(sessionID); return false; },
67
+ kickoff: async (sessionID) => kickoffs.push(sessionID),
68
+ });
69
+ consumer.onStarted(execution('run-1', 'session-a'));
70
+ await consumer.onSucceeded(execution('run-1', 'session-a'));
71
+ assert.deepEqual([offers.length, kickoffs.length, asked], [0, 0, ['session-a']], mode);
72
+ }
73
+ const offers = [];
74
+ const failing = createNativeReviewConsumer({ mode: () => 'ask', diff: makeDiff(), offer: async (offer) => offers.push(offer), eligible: async () => { throw new Error('rpc down'); }, kickoff: async () => {} });
75
+ failing.onStarted(execution('run-1', 'session-a'));
76
+ await failing.onSucceeded(execution('run-1', 'session-a'));
77
+ assert.equal(offers.length, 0, 'an unknown delivery state fails closed');
78
+ });
79
+
61
80
  test('yes kicks off only successful new-change runs and suppresses only that session loop', async () => {
62
81
  const loops = new Set();
63
82
  const started = [];
@@ -0,0 +1,23 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { clickSelect } from '../plugins/carthagent/src/ui/select-mouse.ts';
4
+
5
+ const node = (scrollOffset, linesPerItem) => {
6
+ const calls = [];
7
+ return { calls, scrollOffset, linesPerItem, maxVisibleItems: 3, screenY: 10, options: [{}, {}, {}, {}], setSelectedIndex: i => calls.push(['set', i]), selectCurrent: () => calls.push(['select']) };
8
+ };
9
+
10
+ test('clicks activate the row under the pointer, accounting for scroll and row height', () => {
11
+ const n = node(1, 2);
12
+ clickSelect(n, { button: 0, y: 13 });
13
+ assert.deepEqual(n.calls, [['set', 2], ['select']]);
14
+ });
15
+
16
+ test('clicks outside rows or with other buttons are ignored', () => {
17
+ for (const event of [{ button: 0, y: 9 }, { button: 0, y: 30 }, { button: 0, y: 13 }, { button: 2, y: 10 }]) {
18
+ const n = node(0, 1);
19
+ clickSelect(n, event);
20
+ assert.deepEqual(n.calls, []);
21
+ }
22
+ clickSelect(undefined, { button: 0, y: 10 });
23
+ });
@@ -5,12 +5,12 @@ import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { runInNewContext } from 'node:vm';
7
7
  import { createHash } from 'node:crypto';
8
- import { CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 } from '../lib/opencode/runtime.mjs';
8
+ import { CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, CTG_RUNTIME_PATCH_SHA256 } from '../lib/opencode/runtime.mjs';
9
9
  import ts from 'typescript';
10
10
  import { parseOpenCodeArgs } from '../lib/opencode/cli.mjs';
11
11
  import { normalizeSessionOptions } from '../lib/opencode/session-options.mjs';
12
12
 
13
- const patch = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch', import.meta.url), 'utf8');
13
+ const patch = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url), 'utf8');
14
14
  const path = 'packages/tui/src/routes/session/plan-items.ts';
15
15
  const section = patch.split(`diff --git a/${path} b/${path}\n`)[1]?.split('\ndiff --git ')[0];
16
16
  assert.ok(section, 'the plan graph parser is part of the reproducible runtime patch');
@@ -67,9 +67,19 @@ test('native CLI preserves bounded-run and advisor controls in session options',
67
67
  });
68
68
 
69
69
 
70
- test('development runtime source and build recipe pin the actual Ship graph patch bytes', () => {
70
+ test('development runtime source and preparation recipe pin the actual Ship graph patch bytes', () => {
71
71
  const digest = createHash('sha256').update(patch).digest('hex');
72
72
  assert.equal(CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, digest);
73
+ const prepare = readFileSync(new URL('../script/ctg/prepare-development-runtime.sh', import.meta.url), 'utf8');
74
+ assert.ok(prepare.includes(`EXPECTED_PATCH="${digest}"`));
75
+ assert.ok(prepare.includes('docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch'));
76
+ assert.doesNotMatch(prepare, /bun (install|run|build)|gh release|npm publish/);
77
+ });
78
+
79
+ test('the shared release build recipe stays on the published patch', () => {
80
+ const published = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url));
81
+ const digest = createHash('sha256').update(published).digest('hex');
82
+ assert.equal(CTG_RUNTIME_PATCH_SHA256, digest);
73
83
  const build = readFileSync(new URL('../script/ctg/build-owned-runtime.sh', import.meta.url), 'utf8');
74
84
  assert.ok(build.includes(`EXPECTED_PATCH="${digest}"`));
75
85
  });
@@ -40,6 +40,22 @@ function host(t) {
40
40
  return { ctx, tools, hooks, commands, stores, directory, session, sessions, interrupts }
41
41
  }
42
42
 
43
+ test('delivery_review coverage schema accepts assertion aliases models emit', async t => {
44
+ const h = host(t)
45
+ await registerDelivery(h.ctx, { workflow: 'standard' })
46
+ const item = h.tools.get('delivery_review').input.properties.coverage.items
47
+ assert.deepEqual(item.required, ['requirement'])
48
+ assert.deepEqual(item.properties.assertions.anyOf.map(s => s.type), ['string', 'array'])
49
+ assert.equal(item.properties.assertion.type, 'string')
50
+ })
51
+
52
+ test('code-mode catalog always lists the delivery_plan entry point', async t => {
53
+ const h = host(t)
54
+ await registerDelivery(h.ctx, { workflow: 'standard' })
55
+ const pinned = [...h.tools.values()].filter(def => def.options?.pinned).map(def => def.name)
56
+ assert.deepEqual(pinned, ['delivery_plan'])
57
+ })
58
+
43
59
  test('OpenCode V2 registration exposes domain tools and returns complete cleanup', async t => {
44
60
  const h = host(t)
45
61
  const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
@@ -106,7 +106,7 @@ function workflowJob(name) {
106
106
  return lines.slice(start, end).join('\n');
107
107
  }
108
108
 
109
- const combinedPatchPath = 'docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch';
109
+ const combinedPatchPath = 'docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch';
110
110
 
111
111
  function sha256(bytes) {
112
112
  return createHash('sha256').update(bytes).digest('hex');
@@ -250,7 +250,7 @@ const patch=await readFile(path.join(repo,${JSON.stringify(combinedPatchPath)}))
250
250
  const expected=recipe.match(/^EXPECTED_PATCH="([^"]+)"$/m)?.[1];
251
251
  if(!expected || sha(patch)!==expected) throw new Error('recipe patch digest mismatch: expected='+expected+' actual='+sha(patch)+' path='+${JSON.stringify(combinedPatchPath)});
252
252
  const runtimeSource=await readFile(path.join(repo,'lib/opencode/runtime.mjs'),'utf8');
253
- const runtimePatch=runtimeSource.match(/CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = '([a-f0-9]{64})'/)?.[1];
253
+ const runtimePatch=runtimeSource.match(/CTG_RUNTIME_PATCH_SHA256 = '([a-f0-9]{64})'/)?.[1];
254
254
  if(runtimePatch!==expected) throw new Error('fixture packaged runtime trust root patch digest mismatch');
255
255
  const manifest={version:1,releaseVersion:version,compatibilityVersion:'2.0.32',runtimeVersion:'0.0.0-ctg.2.0.32',sourceRevision:'${sourceCommit}',patchSha256:expected,lockSha256:'${lockSha256}',assets:Object.fromEntries(keys.map((key,i)=>[key,{name:names[i],archiveSha256:'0'.repeat(64),executableSha256:'0'.repeat(64)}]))};
256
256
  for(let i=0;i<names.length;i++) {
@@ -403,9 +403,9 @@ test('draft metadata validation has scoped write capability; artifact mutation s
403
403
  assert.doesNotMatch(assembly, /contents: write/);
404
404
  assert.match(publish, /contents: write/);
405
405
  assert.match(uploadBlock, /gh release upload/);
406
- assert.match(workflow, /git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v4\.patch > opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v4\.patch/);
407
- assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v4\.patch\)"/);
408
- assert.equal((workflow.match(/git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v4\.patch > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/g) ?? []).length, 1,
406
+ assert.match(workflow, /git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch > opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch/);
407
+ assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch\)"/);
408
+ assert.equal((workflow.match(/git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/g) ?? []).length, 1,
409
409
  'candidate assembly must extract exact committed bytes at the pinned historical path');
410
410
  assert.match(verifyBlock, /git -C ctg show HEAD:script\/ctg\/build-owned-runtime\.sh/);
411
411
  assert.match(verifyBlock, /git -C ctg show "HEAD:\$patch_file" > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/);
@@ -413,7 +413,7 @@ test('draft metadata validation has scoped write capability; artifact mutation s
413
413
  assert.match(workflow, /chmod \+x opencode\/script\/ctg\/build-owned-runtime\.sh/);
414
414
  assert.match(workflow, /GIT_CONFIG_KEY_0:\s*core\.autocrlf[\s\S]*?GIT_CONFIG_VALUE_0:\s*'false'/);
415
415
  assert.match(workflow, /git -C opencode config --local core\.autocrlf false/);
416
- assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v4\.patch\)"/);
416
+ assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch\)"/);
417
417
  });
418
418
 
419
419
  test('review-branch prebuild source is exact dispatch SHA; publish validation is default-branch-only', { skip: requiresUbuntuWorkflowShell, timeout: 60000 }, async (t) => {
@@ -82,10 +82,10 @@ async function fixture(t, patchSha256 = developmentPatch) {
82
82
  writeManifest, writeSums, calls, spawns, spawnVersion, spawnImpl, publicOptions };
83
83
  }
84
84
 
85
- test('published preview.12 release trust stays valid independently of the development patch pin', async t => {
86
- assert.equal(CTG_RUNTIME_PATCH_SHA256, '1f2309bb3a31dfb8e8a0827a6a1144a1f76a005abff58f6f48857639ec02478a');
85
+ test('published preview.13 release trust stays valid independently of the development patch pin', async t => {
86
+ assert.equal(CTG_RUNTIME_PATCH_SHA256, 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975');
87
87
  const published = await readRuntimeReleaseManifest();
88
- assert.equal(published.releaseVersion, '1.1.0-preview.12');
88
+ assert.equal(published.releaseVersion, '1.1.0-preview.13');
89
89
  const f = await fixture(t, CTG_RUNTIME_PATCH_SHA256);
90
90
  assert.deepEqual(await f.runtime.readRuntimeReleaseManifest(f.releasePath), f.release);
91
91
  assert.equal(await f.runtime.resolveOpenCode(f.publicOptions), f.binary);
@@ -98,7 +98,7 @@ test('published preview.12 release trust stays valid independently of the develo
98
98
  assert.equal(acquisition.expectedCompatibilityVersion, f.runtime.OPENCODE_VERSION);
99
99
  assert.equal(acquisition.expectedRuntimeVersion, f.runtime.CTG_RUNTIME_VERSION);
100
100
  assert.equal(acquisition.expectedChannel, f.manifest.channel);
101
- assert.match(acquisition.baseUrl, /\/v1\.1\.0-preview\.12$/);
101
+ assert.match(acquisition.baseUrl, /\/v1\.1\.0-preview\.13$/);
102
102
  const identity = await f.runtime.runtimeIdentity(f.binary, f.publicOptions);
103
103
  assert.equal(identity.kind, 'ctg-owned');
104
104
  assert.equal(identity.patchSha256, CTG_RUNTIME_PATCH_SHA256);
@@ -117,7 +117,7 @@ test('public release manifest cannot promote the development pin or unpinned pro
117
117
  for (const changes of [
118
118
  { patchSha256: developmentPatch }, { sourceRevision: 'e'.repeat(40) },
119
119
  { lockSha256: 'e'.repeat(64) }, { runtimeVersion: '0.0.0-ctg.2.0.33' },
120
- { compatibilityVersion: '2.0.33' }, { releaseVersion: '1.1.0-preview.13' },
120
+ { compatibilityVersion: '2.0.33' }, { releaseVersion: '1.1.0-preview.14' },
121
121
  ]) {
122
122
  await writeFile(f.releasePath, JSON.stringify({ ...f.release, ...changes }));
123
123
  await assert.rejects(f.runtime.resolveOpenCode(f.publicOptions), /Invalid or incompatible.*release manifest/);
@@ -79,7 +79,9 @@ test('dashboard status reports an available update from the release cache', asyn
79
79
  // Seed the throttled cache so the check resolves without touching the network.
80
80
  const cachePath = join(dir, 'update-check.json');
81
81
  writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), release: { version: '99.0.0', tag: 'v99.0.0' } }));
82
- const server = await serverFixture(t, { CARTHAGENT_UPDATE_CHECK_FILE: cachePath });
82
+ const channelPath = join(dir, 'update-channel.json');
83
+ writeFileSync(channelPath, JSON.stringify({ version: 1, channel: 'stable' }));
84
+ const server = await serverFixture(t, { CARTHAGENT_UPDATE_CHECK_FILE: cachePath, CARTHAGENT_UPDATE_CHANNEL_FILE: channelPath });
83
85
  let body = {};
84
86
  for (let i = 0; i < 50 && !body.update; i++) {
85
87
  body = await (await fetch(`${server.url}/api/status`, { headers: { 'x-carthagent-token': server.token } })).json();
@@ -234,7 +234,7 @@ test('update check sends the install headers to the control plane only', async t
234
234
  const agentDir = tmp(t);
235
235
  const seen = [];
236
236
  const { release, source } = await fetchLatestRelease({
237
- cloudUrl: 'https://cloud.test', env: {}, agentDir,
237
+ cloudUrl: 'https://cloud.test', env: {}, agentDir, channel: 'stable',
238
238
  fetchImpl: async (url, options) => {
239
239
  seen.push({ url, headers: options.headers });
240
240
  if (url.includes('/v1/cli/releases/latest')) return { status: 503, json: async () => ({}) };