carthagent 0.0.0-stage → 1.0.7
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.
- package/LICENSE +21 -0
- package/README.md +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseD2, layoutD2, renderD2 } from '../lib/tui/d2.mjs';
|
|
4
|
+
import { syncActiveModel, planSideWidth, makeKeyParser, matchSlash, slashCardDimensions, renderSlashCard, computeNodeStates, barRange, scrollCell, paneClipCols, cloudManagedUsageNotice, isCloudCreditExhaustion } from '../lib/tui/app.mjs';
|
|
5
|
+
import { UNICODE_GLYPHS, ASCII_GLYPHS, detectGlyphMode, resolveGlyphs } from '../lib/tui/glyphs.mjs';
|
|
6
|
+
import { checkDigest, planD2, computePhase, freshChecks, PHASES } from '../lib/delivery.mjs';
|
|
7
|
+
import { createFeed, resetFeed, applyEvent, summarizeArgs, renderFeed, hydrateFeed } from '../lib/tui/feed.mjs';
|
|
8
|
+
import { CARTHAGE_LINES } from '../lib/tui/carthage.mjs';
|
|
9
|
+
import { strip, width, wrap, truncate, hasTruecolor, sliceCols, inverseCols, mix, fg } from '../lib/tui/ansi.mjs';
|
|
10
|
+
import { getTheme, resolveThemeName, flattenTheme, THEME_NAMES } from '../lib/tui/theme.mjs';
|
|
11
|
+
import { framePrompt, shouldFrame, unframe, frameHint, FRAME_HINTS } from '../lib/tui/framing.mjs';
|
|
12
|
+
import { listSessions, mostRecentSession, sessionDirFor, buildEngineArgs } from '../lib/engine.mjs';
|
|
13
|
+
import { loadScenario, listScenarios, expandPlaceholders } from '../lib/scenarios.mjs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, resolve, join } from 'node:path';
|
|
16
|
+
import { mkdtempSync, writeFileSync, rmSync, utimesSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
test('compaction notices distinguish success, failure and cancellation', () => {
|
|
22
|
+
for (const [event, expected] of [
|
|
23
|
+
[{errorMessage:'Summarization failed: request_too_large'},'compaction failed'],
|
|
24
|
+
[{aborted:true},'compaction aborted'],
|
|
25
|
+
[{},'context compacted'],
|
|
26
|
+
]) {
|
|
27
|
+
const feed=createFeed();
|
|
28
|
+
applyEvent(feed,{type:'compaction_end',...event});
|
|
29
|
+
assert.ok(feed.blocks.at(-1).text.startsWith(expected));
|
|
30
|
+
if (event.errorMessage) assert.equal(feed.blocks.at(-1).tone,'error');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('Cloud managed-usage exhaustion is detected only for failed Cloud assistant messages', () => {
|
|
35
|
+
assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'insufficient_quota' } }), true);
|
|
36
|
+
assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', stopReason: 'error', errorMessage: 'credit is exhausted' } }, 'experiential-labs/model'), true);
|
|
37
|
+
assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: '429: {"error":{"code":"daily_limit_reached"}}' } }), /00:00 UTC/);
|
|
38
|
+
assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'monthly_allowance_exhausted' } }), /billing portal/);
|
|
39
|
+
assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'subscription_inactive' } }), /subscription is inactive/);
|
|
40
|
+
assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', provider: 'openai', stopReason: 'error', errorMessage: 'daily_limit_reached' } }), false);
|
|
41
|
+
assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'stop' } }), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('slash card follows every selection down and back up', () => {
|
|
45
|
+
const items = matchSlash('/');
|
|
46
|
+
for (const sel of [...items.keys(), ...[...items.keys()].reverse()]) {
|
|
47
|
+
const { card } = renderSlashCard(items, sel, 140);
|
|
48
|
+
const selected = card.map(strip).filter(line => line.includes('●'));
|
|
49
|
+
assert.equal(selected.length, 1);
|
|
50
|
+
assert.ok(selected[0].includes(items[sel].name + ' '));
|
|
51
|
+
assert.equal(card.length, 11);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('active model synchronization refreshes welcome blocks without redundant invalidation', () => {
|
|
56
|
+
const state = { model: 'old/model' };
|
|
57
|
+
const feed = createFeed();
|
|
58
|
+
feed.blocks.push({ kind: 'welcome', model: 'old/model' }, { kind: 'notice', text: 'keep' });
|
|
59
|
+
const version = feed.version;
|
|
60
|
+
const model = { provider: 'openrouter', id: 'anthropic/claude-opus-4.6' };
|
|
61
|
+
syncActiveModel(state, feed, model);
|
|
62
|
+
assert.equal(state.model, 'openrouter/anthropic/claude-opus-4.6');
|
|
63
|
+
assert.equal(feed.blocks[0].model, state.model);
|
|
64
|
+
assert.equal(feed.version, version + 1);
|
|
65
|
+
syncActiveModel(state, feed, model);
|
|
66
|
+
syncActiveModel(state, feed, undefined);
|
|
67
|
+
assert.equal(feed.version, version + 1);
|
|
68
|
+
assert.equal(feed.blocks[1].text, 'keep');
|
|
69
|
+
|
|
70
|
+
syncActiveModel(state, feed, { provider: 'experiential-labs', id: 'gpt-6-luna' });
|
|
71
|
+
assert.equal(state.model, 'experiential-labs/gpt-6-luna');
|
|
72
|
+
assert.equal(feed.blocks[0].model, 'Carthagent Ship/gpt-6-luna');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const SAMPLE_PLAN = {
|
|
76
|
+
goal: 'Build a task CLI',
|
|
77
|
+
assumptions: ['stdlib only'],
|
|
78
|
+
artifacts: ['.'],
|
|
79
|
+
steps: ['slice', 'persist', 'tests'],
|
|
80
|
+
acceptance: [{ requirement: 'commands work', checks: ['tests'] }],
|
|
81
|
+
checks: [{ id: 'tests', kind: 'test', argv: ['node', '--test'], timeoutSeconds: 30 }],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
test('parseD2 parses planD2 output into nodes and edges', () => {
|
|
85
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
86
|
+
const ids = g.nodes.map(n => n.id);
|
|
87
|
+
for (const id of ['goal', 'step0', 'step1', 'step2', 'verify', 'review', 'repair', 'deliver']) assert.ok(ids.includes(id), `missing ${id}`);
|
|
88
|
+
assert.equal(g.nodes.find(n => n.id === 'goal').label, 'Build a task CLI');
|
|
89
|
+
assert.ok(g.edges.some(e => e.from === 'verify' && e.to === 'repair' && e.label === 'failure'));
|
|
90
|
+
assert.ok(g.edges.some(e => e.from === 'repair' && e.to === 'verify'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('layoutD2 ranks spine linearly and puts repair in the side column', () => {
|
|
94
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
95
|
+
const { rank, col, back } = layoutD2(g);
|
|
96
|
+
assert.equal(rank.get('goal'), 0);
|
|
97
|
+
assert.equal(rank.get('step0'), 1);
|
|
98
|
+
assert.equal(rank.get('verify'), 4);
|
|
99
|
+
assert.equal(rank.get('review'), 5);
|
|
100
|
+
assert.equal(rank.get('deliver'), 6);
|
|
101
|
+
assert.equal(col.get('repair'), 1);
|
|
102
|
+
assert.equal(back.size, 1);
|
|
103
|
+
assert.ok([...back][0].from === 'repair' && [...back][0].to === 'verify');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('renderD2 emits styled lines that fit the requested width', () => {
|
|
107
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
108
|
+
const states = new Map([['goal', 'done'], ['step0', 'active']]);
|
|
109
|
+
const { lines, hotRow, frontierId, frontierLabel } = renderD2(g, { width: 44, theme: getTheme('opencode'), states, frame: 2 });
|
|
110
|
+
assert.ok(lines.length >= 7 * 3); // variable-height boxes: >=3 rows per rank
|
|
111
|
+
for (const l of lines) assert.ok(width(l) <= 44, `overflow: ${JSON.stringify(l)}`);
|
|
112
|
+
const plain = strip(lines.join('\n'));
|
|
113
|
+
assert.match(plain, /goal|task CLI/i);
|
|
114
|
+
assert.ok(hotRow > 0);
|
|
115
|
+
assert.equal(frontierId, 'step0');
|
|
116
|
+
assert.match(frontierLabel, /slice/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('renderD2 breathes the arrowhead into the active node', () => {
|
|
120
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
121
|
+
const theme = getTheme('mono');
|
|
122
|
+
const states = new Map([['goal', 'done'], ['step0', 'active'], ['verify', 'active']]);
|
|
123
|
+
const plain = f => strip(renderD2(g, { width: 44, theme, states, frame: f }).lines.join('\n'));
|
|
124
|
+
// Hollow ~1/4 of the cycle, solid the rest — forward ▼ and back-edge ◀ alike.
|
|
125
|
+
assert.ok(plain(0).includes('▽'));
|
|
126
|
+
assert.ok(plain(0).includes('◁'));
|
|
127
|
+
assert.ok(!plain(2).includes('▽'));
|
|
128
|
+
assert.ok(!plain(2).includes('◁'));
|
|
129
|
+
assert.ok(plain(2).includes('▼'));
|
|
130
|
+
assert.ok(plain(2).includes('◀'));
|
|
131
|
+
// No active target → arrowheads stay solid at every frame.
|
|
132
|
+
const idle = new Map([['goal', 'done'], ['step0', 'done']]);
|
|
133
|
+
for (const f of [0, 1, 2]) {
|
|
134
|
+
const p = strip(renderD2(g, { width: 44, theme, states: idle, frame: f }).lines.join('\n'));
|
|
135
|
+
assert.ok(!p.includes('▽') && !p.includes('◁'), `frame ${f}`);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('renderD2 swirls a comet around the active node border', () => {
|
|
140
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
141
|
+
const theme = getTheme('opencode');
|
|
142
|
+
const accent = theme.primary || theme.accent;
|
|
143
|
+
// The comet head is the only border cell mixed toward pure white, so its
|
|
144
|
+
// exact SGR sequence is a stable fingerprint the base border pulse never hits.
|
|
145
|
+
const head = fg(mix(accent, '#ffffff', 0.55));
|
|
146
|
+
const pos = (f, states) => {
|
|
147
|
+
const { lines } = renderD2(g, { width: 44, theme, states, frame: f });
|
|
148
|
+
for (let y = 0; y < lines.length; y++) {
|
|
149
|
+
const at = lines[y].indexOf(head);
|
|
150
|
+
if (at < 0) continue;
|
|
151
|
+
const ch = lines[y].slice(at + head.length).replace(/\x1b\[[\d;]*m/g, '')[0];
|
|
152
|
+
if (/[=\u250c-\u257f]/.test(ch)) return `${y}:${strip(lines[y].slice(0, at)).length}`;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
156
|
+
const active = new Map([['goal', 'done'], ['step0', 'active']]);
|
|
157
|
+
assert.ok(pos(0, active) != null, 'comet head present on the active node');
|
|
158
|
+
assert.notEqual(pos(0, active), pos(7, active), 'comet orbits the perimeter between frames');
|
|
159
|
+
const idle = new Map([['goal', 'done'], ['step0', 'done']]);
|
|
160
|
+
const { lines } = renderD2(g, { width: 44, theme, states: idle, frame: 0 });
|
|
161
|
+
assert.ok(!lines.join('\n').includes(head), 'no comet when no active node');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
test('renderD2 works at tiny widths without crashing', () => {
|
|
166
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
167
|
+
for (const w of [12, 20, 30, 34, 44, 60, 80]) {
|
|
168
|
+
const { lines, hotRow } = renderD2(g, { width: w, theme: getTheme('mono'), states: new Map(), frame: 0 });
|
|
169
|
+
for (const l of lines) assert.ok(width(l) <= w, `overflow w=${w}`);
|
|
170
|
+
assert.ok(hotRow >= 0 && hotRow < lines.length);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('renderD2 light themes use plain panel fills (text stays readable)', () => {
|
|
175
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
176
|
+
for (const name of ['paper', 'daylight', 'solarized-light', 'contrast-light', 'okabe-light']) {
|
|
177
|
+
const theme = getTheme(name, 'light');
|
|
178
|
+
const states = new Map([['goal', 'done'], ['step0', 'active'], ['verify', 'fail']]);
|
|
179
|
+
const { lines } = renderD2(g, { width: 60, theme, states, frame: 0 });
|
|
180
|
+
// The fill for every state on a light theme is the panel background —
|
|
181
|
+
// text on the fill is text on the panel, which passes WCAG AA by design.
|
|
182
|
+
assert.ok(lines.length > 0, `${name} rendered`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('renderD2 wraps long labels across rows instead of truncating', () => {
|
|
187
|
+
const plan = { ...SAMPLE_PLAN, steps: ['inspect and probe the existing runtime and APIs before writing any code'] };
|
|
188
|
+
const g = parseD2(planD2(plan));
|
|
189
|
+
const { lines } = renderD2(g, { width: 44, theme: getTheme('opencode'), states: new Map(), frame: 0 });
|
|
190
|
+
const plain = strip(lines.join('\n'));
|
|
191
|
+
// First wrapped row is ellipsis-free; full words survive across rows.
|
|
192
|
+
assert.match(plain, /inspect and probe/);
|
|
193
|
+
assert.match(plain, /before writing/);
|
|
194
|
+
for (const l of lines) assert.ok(width(l) <= 44);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('renderD2 exposes the frontier for the rail detail line', () => {
|
|
198
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
199
|
+
const r = renderD2(g, { width: 44, theme: getTheme('opencode'), states: new Map([['goal', 'done'], ['verify', 'fail']]), frame: 0 });
|
|
200
|
+
assert.equal(r.frontierId, 'verify');
|
|
201
|
+
assert.ok(r.frontierLabel.length > 0);
|
|
202
|
+
assert.ok(Array.isArray(r.boxes), 'boxes metadata should be returned');
|
|
203
|
+
assert.ok(r.boxes.length > 0, 'boxes should contain nodes');
|
|
204
|
+
for (const b of r.boxes) {
|
|
205
|
+
assert.ok(b.id, 'box has id');
|
|
206
|
+
assert.ok(typeof b.top === 'number' && typeof b.bottom === 'number');
|
|
207
|
+
assert.ok(b.bottom >= b.top + 2, `box ${b.id} must be at least 3 rows tall`);
|
|
208
|
+
assert.equal(b.h, b.bottom - b.top + 1);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('renderD2 preserves complete box borders and does not overflow width', () => {
|
|
213
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
214
|
+
for (const w of [20, 30, 44, 60]) {
|
|
215
|
+
const r = renderD2(g, { width: w, theme: getTheme('opencode'), states: new Map(), frame: 0 });
|
|
216
|
+
for (const l of r.lines) {
|
|
217
|
+
assert.ok(width(l) <= w, `line width ${width(l)} must not exceed ${w}`);
|
|
218
|
+
}
|
|
219
|
+
for (const b of r.boxes) {
|
|
220
|
+
// Top border of box b must contain top corner characters and horizontal line
|
|
221
|
+
const topRow = strip(r.lines[b.top]);
|
|
222
|
+
const botRow = strip(r.lines[b.bottom]);
|
|
223
|
+
assert.ok(topRow.includes('╭') || topRow.includes('╔'), `top border exists for ${b.id}`);
|
|
224
|
+
assert.ok(botRow.includes('╰') || botRow.includes('╚'), `bottom border exists for ${b.id}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('planSideWidth tiers widen the rail on wide terminals', () => {
|
|
230
|
+
// Tier boundaries: <120 legacy, 120/160/200 steps up. Feed guard keeps 36.
|
|
231
|
+
assert.equal(planSideWidth(100), Math.max(34, Math.min(56, Math.floor(100 * 0.36))));
|
|
232
|
+
assert.equal(planSideWidth(120), Math.min(64, Math.floor(120 * 0.38)));
|
|
233
|
+
assert.equal(planSideWidth(160), Math.min(72, Math.floor(160 * 0.40)));
|
|
234
|
+
assert.equal(planSideWidth(200), Math.min(84, Math.floor(200 * 0.42)));
|
|
235
|
+
assert.ok(planSideWidth(200) > planSideWidth(100));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('renderD2 works with legacy theme aliases via opencode tokens', () => {
|
|
239
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
240
|
+
for (const name of ['opencode', 'tokyonight', 'nebula', 'ember', 'forest', 'mono']) {
|
|
241
|
+
const theme = getTheme(name);
|
|
242
|
+
assert.ok(theme.primary && theme.text && theme.border, `missing tokens: ${name}`);
|
|
243
|
+
assert.ok(theme.diffAdded && theme.diffRemoved, `missing diff tokens: ${name}`);
|
|
244
|
+
assert.ok(theme.markdownCode && theme.syntaxKeyword, `missing md/syntax tokens: ${name}`);
|
|
245
|
+
const { lines } = renderD2(g, { width: 40, theme, states: new Map(), frame: 0 });
|
|
246
|
+
for (const l of lines) assert.ok(width(l) <= 40, `overflow ${name}`);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('feed reducer handles a full run lifecycle', () => {
|
|
251
|
+
const S = createFeed();
|
|
252
|
+
applyEvent(S, { type: 'agent_start' });
|
|
253
|
+
applyEvent(S, { type: 'turn_start' });
|
|
254
|
+
applyEvent(S, { type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'build x' }] } });
|
|
255
|
+
applyEvent(S, { type: 'message_start', message: { role: 'assistant', content: [] } });
|
|
256
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'working ' } });
|
|
257
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'on it' } });
|
|
258
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'toolcall_start', contentIndex: 1, id: 't1', toolName: 'bash' } });
|
|
259
|
+
applyEvent(S, { type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { command: 'npm test' } });
|
|
260
|
+
applyEvent(S, { type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', isError: false, result: { content: [{ type: 'text', text: 'ok\npassed' }] } });
|
|
261
|
+
applyEvent(S, { type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'working on it' }], usage: { totalTokens: 100, cost: { total: 0.01 } }, stopReason: 'stop' } });
|
|
262
|
+
applyEvent(S, { type: 'agent_end', messages: [], willRetry: false });
|
|
263
|
+
applyEvent(S, { type: 'agent_settled' });
|
|
264
|
+
|
|
265
|
+
assert.equal(S.turns, 1);
|
|
266
|
+
assert.equal(S.tokens, 100);
|
|
267
|
+
assert.ok(!S.running);
|
|
268
|
+
assert.ok(S.settled);
|
|
269
|
+
const tool = S.blocks.find(b => b.kind === 'tool');
|
|
270
|
+
assert.equal(tool.status, 'done');
|
|
271
|
+
assert.deepEqual(tool.tail.slice(-1), ['passed']);
|
|
272
|
+
const user = S.blocks.find(b => b.kind === 'user');
|
|
273
|
+
assert.equal(user.text, 'build x');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('run completion is announced only after the whole run settles, with Aleph and a brief flourish', () => {
|
|
277
|
+
const S = createFeed();
|
|
278
|
+
applyEvent(S, { type: 'agent_start' });
|
|
279
|
+
applyEvent(S, { type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] } });
|
|
280
|
+
applyEvent(S, { type: 'message_start', message: { role: 'assistant', content: [] } });
|
|
281
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Done.' } });
|
|
282
|
+
applyEvent(S, { type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Done.' }], stopReason: 'stop' } });
|
|
283
|
+
assert.equal(S.blocks.some(b => b.kind === 'completion'), false, 'assistant message alone is not the whole run');
|
|
284
|
+
applyEvent(S, { type: 'agent_end', messages: [] });
|
|
285
|
+
assert.equal(S.blocks.some(b => b.kind === 'completion'), false, 'wait for settled event');
|
|
286
|
+
applyEvent(S, { type: 'agent_settled' });
|
|
287
|
+
const completion = S.blocks.find(b => b.kind === 'completion');
|
|
288
|
+
assert.ok(completion);
|
|
289
|
+
const early = strip(renderFeed(S, 72, getTheme('opencode'), { now: completion.startedAt + 100, frame: 0 }).lines.join('\n'));
|
|
290
|
+
const late = strip(renderFeed(S, 72, getTheme('opencode'), { now: completion.startedAt + 1500, frame: 0 }).lines.join('\n'));
|
|
291
|
+
assert.ok(CARTHAGE_LINES.includes(completion.line), 'completion picks a Carthage line');
|
|
292
|
+
assert.match(early, new RegExp(`Run complete.*${completion.line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
|
293
|
+
assert.match(late, new RegExp(`Run complete.*${completion.line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
|
294
|
+
assert.notEqual(early, late, 'completion flourish changes during its short animation');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('feed dedupes a locally echoed user prompt', () => {
|
|
298
|
+
const S = createFeed();
|
|
299
|
+
S.blocks.push({ kind: 'user', text: 'hi', t: Date.now() });
|
|
300
|
+
applyEvent(S, { type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } });
|
|
301
|
+
assert.equal(S.blocks.filter(b => b.kind === 'user').length, 1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('resetFeed clears blocks, tools, and resets tokens and cost counters', () => {
|
|
305
|
+
const S = createFeed();
|
|
306
|
+
applyEvent(S, { type: 'agent_start' });
|
|
307
|
+
applyEvent(S, { type: 'turn_start' });
|
|
308
|
+
applyEvent(S, { type: 'message_start', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } });
|
|
309
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'done' } });
|
|
310
|
+
applyEvent(S, { type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], usage: { totalTokens: 350, cost: { total: 0.05 } }, stopReason: 'stop' } });
|
|
311
|
+
assert.equal(S.tokens, 350);
|
|
312
|
+
assert.equal(S.cost, 0.05);
|
|
313
|
+
assert.ok(S.blocks.length > 0);
|
|
314
|
+
|
|
315
|
+
resetFeed(S);
|
|
316
|
+
assert.equal(S.tokens, 0);
|
|
317
|
+
assert.equal(S.cost, 0);
|
|
318
|
+
assert.equal(S.toolCalls, 0);
|
|
319
|
+
assert.equal(S.turns, 0);
|
|
320
|
+
assert.equal(S.blocks.length, 0);
|
|
321
|
+
assert.equal(S.toolBlocks.size, 0);
|
|
322
|
+
assert.equal(S.currentAssistant, null);
|
|
323
|
+
assert.equal(S.currentThinking, null);
|
|
324
|
+
assert.equal(S.running, false);
|
|
325
|
+
assert.equal(S.settled, true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('renderFeed produces width-bounded lines', () => {
|
|
329
|
+
const S = createFeed();
|
|
330
|
+
applyEvent(S, { type: 'message_start', message: { role: 'assistant', content: [] } });
|
|
331
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'A '.repeat(200) } });
|
|
332
|
+
const { lines } = renderFeed(S, 60, getTheme('opencode'), { frame: 0 });
|
|
333
|
+
for (const l of lines) assert.ok(width(l) <= 60);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('renderFeed stays width-bounded across all themes', () => {
|
|
337
|
+
for (const name of THEME_NAMES) {
|
|
338
|
+
const S = createFeed();
|
|
339
|
+
applyEvent(S, { type: 'message_start', message: { role: 'assistant', content: [] } });
|
|
340
|
+
applyEvent(S, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'B '.repeat(120) } });
|
|
341
|
+
applyEvent(S, { type: 'tool_execution_start', toolCallId: 't1', toolName: 'delivery_check', args: { id: 'all' } });
|
|
342
|
+
applyEvent(S, { type: 'tool_execution_end', toolCallId: 't1', toolName: 'delivery_check', isError: true, result: { content: [{ type: 'text', text: 'fail line 1\nfail line 2' }] } });
|
|
343
|
+
const { lines } = renderFeed(S, 60, getTheme(name), { frame: 0 });
|
|
344
|
+
for (const l of lines) assert.ok(width(l) <= 60, `overflow ${name}`);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('summarizeArgs gives concise tool summaries', () => {
|
|
349
|
+
assert.equal(summarizeArgs('read', { path: 'a/b.ts' }), 'a/b.ts');
|
|
350
|
+
assert.match(summarizeArgs('write', { path: 'x', content: 'abcd' }), /x · 4B/);
|
|
351
|
+
assert.equal(summarizeArgs('bash', { command: 'ls -la' }), 'ls -la');
|
|
352
|
+
assert.equal(summarizeArgs('delivery_check', { id: 'all' }), 'id=all');
|
|
353
|
+
assert.equal(summarizeArgs('delivery_revise', { reason: 'Discovered another consumer' }), 'Discovered another consumer');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('ansi helpers measure and clip styled text', () => {
|
|
357
|
+
assert.equal(width('héllo'), 5);
|
|
358
|
+
assert.equal(width('\x1b[31mred\x1b[0m'), 3);
|
|
359
|
+
const w = wrap('the quick brown fox jumps', 10);
|
|
360
|
+
assert.ok(w.length >= 3);
|
|
361
|
+
for (const l of w) assert.ok(width(l) <= 10);
|
|
362
|
+
assert.equal(strip(truncate('\x1b[31mabcdef\x1b[0m', 3)), 'abc');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('themes resolve by name and label, defaulting to carthage', () => {
|
|
366
|
+
assert.equal(resolveThemeName('EMBER'), 'ember');
|
|
367
|
+
assert.equal(resolveThemeName('Carthage'), 'carthage');
|
|
368
|
+
assert.equal(resolveThemeName('Opencode'), 'opencode');
|
|
369
|
+
assert.equal(resolveThemeName('Tokyonight'), 'tokyonight');
|
|
370
|
+
assert.equal(resolveThemeName('nope'), 'carthage');
|
|
371
|
+
assert.equal(resolveThemeName(), 'carthage');
|
|
372
|
+
assert.ok(THEME_NAMES.includes('carthage') && THEME_NAMES.includes('opencode') && THEME_NAMES.includes('tokyonight'));
|
|
373
|
+
// Carthage default: dark Tyrian surfaces, legible gold primary, green success.
|
|
374
|
+
const t = getTheme();
|
|
375
|
+
assert.equal(t.background, '#120d1c');
|
|
376
|
+
assert.equal(t.primary, '#f2c66d');
|
|
377
|
+
assert.equal(t.success, '#76d6aa');
|
|
378
|
+
// legacy aliases still resolve through the flattened palette.
|
|
379
|
+
assert.ok(t.accent && t.ok && t.err && t.faint && t.muted);
|
|
380
|
+
// light mode flattens {dark,light} pairs to the light variant.
|
|
381
|
+
const light = flattenTheme({ defs: { a: '#111111', b: '#222222' }, theme: { primary: { dark: 'a', light: 'b' } } }, 'light');
|
|
382
|
+
assert.equal(light.primary, '#222222');
|
|
383
|
+
assert.equal(typeof hasTruecolor(), 'boolean');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('framing expands the delivery template behind the scenes', () => {
|
|
387
|
+
const out = framePrompt('build a cli');
|
|
388
|
+
assert.match(out, /in phases.*build a cli/);
|
|
389
|
+
assert.match(out, /delivery_plan/);
|
|
390
|
+
assert.match(out, /delivery_progress/);
|
|
391
|
+
// fallback: empty template returns the task
|
|
392
|
+
assert.equal(framePrompt('build a cli', ''), 'build a cli');
|
|
393
|
+
assert.equal(framePrompt('task', 'Do $@ now'), 'Do task now');
|
|
394
|
+
assert.deepEqual(FRAME_HINTS, ['Message carthagent…']);
|
|
395
|
+
assert.equal(frameHint(0), 'Message carthagent…');
|
|
396
|
+
assert.equal(frameHint(500), 'Message carthagent…');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('shouldFrame gates framing to substantial prompts', () => {
|
|
400
|
+
assert.ok(shouldFrame('build a task cli with tests'));
|
|
401
|
+
assert.ok(!shouldFrame('/compact'));
|
|
402
|
+
assert.ok(!shouldFrame('!ls'));
|
|
403
|
+
assert.ok(!shouldFrame('what does index.ts do?'));
|
|
404
|
+
assert.ok(!shouldFrame('what does index.ts do'));
|
|
405
|
+
assert.ok(shouldFrame('what cache layer should I add — implement it'));
|
|
406
|
+
assert.ok(!shouldFrame('build x', { enabled: false }));
|
|
407
|
+
assert.ok(!shouldFrame('build x', { delivery: false }));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('scenario manifests load and expand placeholders', () => {
|
|
411
|
+
const names = listScenarios(ROOT);
|
|
412
|
+
assert.ok(names.includes('cli') && names.includes('game'));
|
|
413
|
+
const cli = loadScenario(ROOT, 'cli');
|
|
414
|
+
assert.match(cli.prompt, /task tracker/i);
|
|
415
|
+
assert.equal(cli.developmentChecks[0].id, 'acceptance');
|
|
416
|
+
const argv = expandPlaceholders(cli.developmentChecks[0].argv, { python: '/py', cwd: '/w', scenario: cli.dir });
|
|
417
|
+
assert.equal(argv[0], '/py');
|
|
418
|
+
assert.ok(argv[1].endsWith('grade_cli.py'));
|
|
419
|
+
assert.equal(argv[2], '/w');
|
|
420
|
+
const game = loadScenario(ROOT, 'game');
|
|
421
|
+
assert.ok(game.seeds.length && game.developmentChecks.length === 2 && game.holdoutChecks.length === 1);
|
|
422
|
+
assert.ok(game.context.includes('game-development'));
|
|
423
|
+
assert.ok(game.needsPython);
|
|
424
|
+
assert.throws(() => loadScenario(ROOT, 'nonexistent'), /Unknown scenario/);
|
|
425
|
+
assert.throws(() => loadScenario(ROOT, '../outside'), /Invalid scenario name/);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('unframe recovers the typed task from framed prompts', () => {
|
|
429
|
+
const expanded = framePrompt('build a cli');
|
|
430
|
+
assert.equal(unframe(expanded), 'build a cli');
|
|
431
|
+
assert.equal(unframe('just a plain prompt'), null);
|
|
432
|
+
assert.equal(unframe(''), null);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('computePhase derives the rail tracker state', () => {
|
|
436
|
+
assert.deepEqual(computePhase(null, 'abc').phase, 'inspect');
|
|
437
|
+
assert.deepEqual(PHASES, ['inspect', 'plan', 'build', 'verify', 'review', 'deliver']);
|
|
438
|
+
const plan = {
|
|
439
|
+
goal: 'g', steps: ['s'], acceptance: [{ requirement: 'r', checks: ['tests'] }],
|
|
440
|
+
checks: [{ id: 'tests', kind: 'test', argv: ['node', '--test'], timeoutSeconds: 30 }],
|
|
441
|
+
};
|
|
442
|
+
assert.equal(computePhase({ plan, evidence: {}, status: 'implementing', revision: 2 }, 'h1').phase, 'plan');
|
|
443
|
+
assert.equal(computePhase({ plan, evidence: {}, status: 'implementing', revision: 2 }, 'h1').revision, 2);
|
|
444
|
+
assert.equal(computePhase({ plan, evidence: {}, status: 'implementing', stepStatus: { step0: 'active' } }, 'h1').phase, 'build');
|
|
445
|
+
assert.equal(computePhase({ plan, evidence: {}, status: 'verifying' }, 'h1', true).phase, 'verify');
|
|
446
|
+
const fresh = { runId: 'run-a', revision: 1, plan, evidence: { tests: { runId: 'run-a', revision: 1, passed: true, fingerprint: 'h1', checkDigest: checkDigest(plan.checks[0]) } }, status: 'implementing' };
|
|
447
|
+
assert.equal(computePhase(fresh, 'h1').phase, 'review');
|
|
448
|
+
const structured = { ...fresh, plan: { ...plan, steps: [{ id: 'review', title: 'Review consumers' }] } };
|
|
449
|
+
assert.equal(computePhase(structured, 'h1').phase, 'build');
|
|
450
|
+
assert.equal(computePhase({ ...structured, stepStatus: { step0: 'done' } }, 'h1').phase, 'review');
|
|
451
|
+
assert.equal(computePhase({ ...fresh, status: 'verifying' }, 'h1').phase, 'review');
|
|
452
|
+
assert.equal(computePhase({ ...fresh, status: 'verifying' }, 'h1', true).phase, 'verify');
|
|
453
|
+
const readyNodes = computeNodeStates({ ...fresh, status: 'verifying' }, false, null, 'h1').states;
|
|
454
|
+
assert.equal(readyNodes.get('verify'), 'done');
|
|
455
|
+
assert.equal(readyNodes.get('review'), 'active');
|
|
456
|
+
assert.deepEqual(freshChecks(fresh, 'h1'), { fresh: 1, total: 1 });
|
|
457
|
+
assert.deepEqual(freshChecks(fresh, 'h2'), { fresh: 0, total: 1 });
|
|
458
|
+
assert.equal(computePhase({ ...fresh, status: 'verified' }, 'h1').phase, 'deliver');
|
|
459
|
+
assert.equal(computePhase({ ...fresh, status: 'blocked' }, 'h1').phase, 'deliver');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('hydrateFeed rebuilds history blocks from session entries', () => {
|
|
463
|
+
const entries = [
|
|
464
|
+
{ type: 'session', id: 's1', timestamp: '2026-01-01T00:00:00Z' },
|
|
465
|
+
{ type: 'message', message: { role: 'user', content: [{ type: 'text', text: framePrompt('do the thing') }] } },
|
|
466
|
+
{ type: 'message', message: { role: 'assistant', content: [
|
|
467
|
+
{ type: 'thinking', thinking: 'hmm' },
|
|
468
|
+
{ type: 'text', text: 'planning' },
|
|
469
|
+
{ type: 'toolCall', id: 'c1', name: 'bash', arguments: { command: 'ls' } },
|
|
470
|
+
], usage: { totalTokens: 10, cost: { total: 0.01 } } } },
|
|
471
|
+
{ type: 'message', message: { role: 'toolResult', toolCallId: 'c1', toolName: 'bash', content: [{ type: 'text', text: 'ok\nline2' }], isError: false } },
|
|
472
|
+
{ type: 'custom', customType: 'delivery-gate' },
|
|
473
|
+
{ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], usage: { totalTokens: 5, cost: { total: 0.005 } } } },
|
|
474
|
+
];
|
|
475
|
+
const S = createFeed();
|
|
476
|
+
hydrateFeed(S, entries);
|
|
477
|
+
const user = S.blocks.find(b => b.kind === 'user');
|
|
478
|
+
assert.equal(user.text, 'do the thing');
|
|
479
|
+
// framed prompts carry no badge — delivery is the default flow.
|
|
480
|
+
assert.equal(user.via, undefined);
|
|
481
|
+
const tool = S.blocks.find(b => b.kind === 'tool');
|
|
482
|
+
assert.equal(tool.name, 'bash');
|
|
483
|
+
assert.equal(tool.status, 'done');
|
|
484
|
+
assert.deepEqual(tool.tail.slice(-1), ['line2']);
|
|
485
|
+
assert.ok(S.blocks.some(b => b.kind === 'thinking'));
|
|
486
|
+
assert.equal(S.tokens, 15);
|
|
487
|
+
assert.equal(S.toolCalls, 1);
|
|
488
|
+
const { lines } = renderFeed(S, 60, getTheme('opencode'), { frame: 0 });
|
|
489
|
+
for (const l of lines) assert.ok(width(l) <= 60);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('hydrateFeed unwraps framed prompts without badges', () => {
|
|
493
|
+
const entries = [
|
|
494
|
+
{ type: 'message', message: { role: 'user', content: [{ type: 'text', text: framePrompt('framed task') }] } },
|
|
495
|
+
];
|
|
496
|
+
const S = createFeed();
|
|
497
|
+
hydrateFeed(S, entries);
|
|
498
|
+
const user = S.blocks.find(b => b.kind === 'user');
|
|
499
|
+
assert.equal(user.text, 'framed task');
|
|
500
|
+
assert.equal(user.via, undefined);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('listSessions reads metadata newest-first and mostRecentSession picks the head', t => {
|
|
504
|
+
const dir = mkdtempSync(join(tmpdir(), 'carthagent sessions '));
|
|
505
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
506
|
+
const mk = (file, id, userText, extra = []) => writeFileSync(join(dir, file), [
|
|
507
|
+
JSON.stringify({ type: 'session', version: 3, id, timestamp: '2026-01-01T00:00:00Z', cwd: '/x' }),
|
|
508
|
+
...extra.map(e => JSON.stringify(e)),
|
|
509
|
+
JSON.stringify({ type: 'message', id: 'm1', message: { role: 'user', content: [{ type: 'text', text: userText }] } }),
|
|
510
|
+
'',
|
|
511
|
+
].join('\n'));
|
|
512
|
+
mk('old_aaaa.jsonl', 'aaaa', 'first task', [{ type: 'session_info', name: 'alpha work' }]);
|
|
513
|
+
mk('new_bbbb.jsonl', 'bbbb', 'second task');
|
|
514
|
+
writeFileSync(join(dir, 'not-a-session.txt'), 'nope');
|
|
515
|
+
utimesSync(join(dir, 'old_aaaa.jsonl'), new Date(1000), new Date(1000));
|
|
516
|
+
utimesSync(join(dir, 'new_bbbb.jsonl'), new Date(2000), new Date(2000));
|
|
517
|
+
const list = listSessions('/unused', { dir });
|
|
518
|
+
assert.equal(list.length, 2);
|
|
519
|
+
assert.equal(list[0].id, 'bbbb');
|
|
520
|
+
assert.equal(list[1].id, 'aaaa');
|
|
521
|
+
assert.equal(list[1].name, 'alpha work');
|
|
522
|
+
assert.equal(list[1].firstMessage, 'first task');
|
|
523
|
+
assert.equal(list[1].messageCount, 1);
|
|
524
|
+
assert.equal(mostRecentSession('/unused', dir), list[0].path);
|
|
525
|
+
assert.equal(sessionDirFor('/home/u/proj', '/agent'), join('/agent', 'sessions', `--${resolve('/home/u/proj').replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`));
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('buildEngineArgs forwards session selection but never --resume', () => {
|
|
529
|
+
const sessionArgs = buildEngineArgs({ session: 'abc123', delivery: false }, { installed: true });
|
|
530
|
+
assert.deepEqual(sessionArgs.slice(0, 2), ['--session', 'abc123']);
|
|
531
|
+
const continueArgs = buildEngineArgs({ continue: true, delivery: false }, { installed: true });
|
|
532
|
+
assert.equal(continueArgs[0], '--continue');
|
|
533
|
+
assert.ok(!buildEngineArgs({ resume: true, delivery: false }, { installed: true }).includes('--resume'));
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('buildEngineArgs keeps console review policy out of engine arguments', () => {
|
|
537
|
+
for (const review of ['ask', 'yes', 'no', undefined]) {
|
|
538
|
+
assert.ok(!buildEngineArgs({ review }).includes('--delivery-review'));
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('ctrl-y is parsed for accepting self-review offers', () => {
|
|
543
|
+
const keys = [];
|
|
544
|
+
const parse = makeKeyParser(k => keys.push(k));
|
|
545
|
+
parse('\x19');
|
|
546
|
+
assert.equal(keys[0].key, 'ctrl-y');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('ctrl-v is parsed so the console can paste from the OS clipboard', () => {
|
|
550
|
+
const keys = [];
|
|
551
|
+
const parse = makeKeyParser(k => keys.push(k));
|
|
552
|
+
parse('\x16');
|
|
553
|
+
assert.equal(keys[0].key, 'ctrl-v');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('mouse SGR events map to press, drag, release, and wheel keys', () => {
|
|
557
|
+
const keys = [];
|
|
558
|
+
const parse = makeKeyParser(k => keys.push(k));
|
|
559
|
+
parse('\x1b[<0;10;5M'); // left press at col 10, row 5
|
|
560
|
+
parse('\x1b[<32;14;5M'); // left drag to col 14
|
|
561
|
+
parse('\x1b[<0;14;5m'); // release
|
|
562
|
+
parse('\x1b[<64;3;3M'); // wheel up
|
|
563
|
+
assert.deepEqual(keys.map(k => k.key), ['mousedown', 'mousedrag', 'mouseup', 'wheelup']);
|
|
564
|
+
assert.deepEqual({ x: keys[0].x, y: keys[0].y, button: keys[0].button }, { x: 10, y: 5, button: 0 });
|
|
565
|
+
assert.equal(keys[1].button, 0);
|
|
566
|
+
assert.equal(keys[2].x, 14);
|
|
567
|
+
// modifier bits are masked off button but preserved in flags; right-button presses don't masquerade as left
|
|
568
|
+
const more = [];
|
|
569
|
+
const parse2 = makeKeyParser(k => more.push(k));
|
|
570
|
+
parse2('\x1b[<4;1;1M'); // shift+left press → still a left mousedown with shift: true
|
|
571
|
+
parse2('\x1b[<34;2;2M'); // right-button drag → button 2
|
|
572
|
+
assert.equal(more[0].button, 0);
|
|
573
|
+
assert.equal(more[0].shift, true);
|
|
574
|
+
assert.equal(more[1].key, 'mousedrag');
|
|
575
|
+
assert.equal(more[1].button, 2);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('key parser handles cmd/ctrl/alt deletion and backspace sequences', () => {
|
|
579
|
+
const keys = [];
|
|
580
|
+
const parse = makeKeyParser(k => keys.push(k));
|
|
581
|
+
parse('\x1b[3;5~'); // ctrl-delete
|
|
582
|
+
parse('\x1b[3;9~'); // cmd-delete
|
|
583
|
+
parse('\x1b[27;5;127~'); // ctrl-backspace
|
|
584
|
+
parse('\x1b[27;9;127~'); // cmd-backspace
|
|
585
|
+
parse('\x1b[127;8u'); // cmd-backspace (Kitty)
|
|
586
|
+
parse('\x1b\x7f'); // alt-backspace
|
|
587
|
+
parse('\x1b[<16;12;20M'); // ctrl-click (button 0 + ctrl bit 16)
|
|
588
|
+
assert.deepEqual(keys.map(k => k.key), [
|
|
589
|
+
'ctrl-delete',
|
|
590
|
+
'cmd-delete',
|
|
591
|
+
'ctrl-backspace',
|
|
592
|
+
'cmd-delete',
|
|
593
|
+
'cmd-delete',
|
|
594
|
+
'alt-backspace',
|
|
595
|
+
'mousedown'
|
|
596
|
+
]);
|
|
597
|
+
assert.equal(keys[6].ctrl, true);
|
|
598
|
+
assert.equal(keys[6].button, 0);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('sliceCols and inverseCols handle display-column ranges', () => {
|
|
602
|
+
assert.equal(sliceCols('hello world', 0, 5), 'hello');
|
|
603
|
+
assert.equal(sliceCols('hello world', 6, 11), 'world');
|
|
604
|
+
assert.equal(sliceCols('he世llo', 0, 4), 'he世'); // wide char occupies 2 columns
|
|
605
|
+
const marked = inverseCols('plain text', 0, 5);
|
|
606
|
+
assert.equal(strip(marked), 'plain text');
|
|
607
|
+
assert.match(marked, /\x1b\[7mplain\x1b\[27m/);
|
|
608
|
+
// selection past end of line → inverse padding fills the requested range
|
|
609
|
+
const padded = inverseCols('ab', 0, 8);
|
|
610
|
+
assert.equal(strip(padded), 'ab ');
|
|
611
|
+
assert.match(padded, /\x1b\[7m {6}\x1b\[27m/);
|
|
612
|
+
// short line inside a mid-selection row starting at column 0
|
|
613
|
+
assert.equal(strip(inverseCols('x', 0, 4)), 'x ');
|
|
614
|
+
// styled input keeps its escapes and stays unaltered outside the range
|
|
615
|
+
const styled = inverseCols('\x1b[31mred\x1b[0m plain', 4, 9);
|
|
616
|
+
assert.equal(strip(styled), 'red plain');
|
|
617
|
+
assert.match(styled, /\x1b\[27m/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test('paneClipCols clamps drag-copy ranges to the anchor pane', () => {
|
|
621
|
+
// 120-col split: feed content 0..72, scrollbar at 73, │ separator at 74,
|
|
622
|
+
// plan content 75..118, plan scrollbar at 119
|
|
623
|
+
const clip = { feedEnd: 73, planStart: 75, planEnd: 119, rowTop: 2, rowBottom: 40 };
|
|
624
|
+
// feed-anchored drag spanning the whole width copies feed text only
|
|
625
|
+
assert.deepEqual(paneClipCols(clip, 5, 10, 0, 120), [0, 73]);
|
|
626
|
+
// a drag inside the feed that stops before the edge keeps its range
|
|
627
|
+
assert.deepEqual(paneClipCols(clip, 5, 10, 10, 40), [10, 40]);
|
|
628
|
+
// plan-anchored drag drops the feed columns and the plan scrollbar
|
|
629
|
+
assert.deepEqual(paneClipCols(clip, 90, 10, 0, 120), [75, 119]);
|
|
630
|
+
assert.deepEqual(paneClipCols(clip, 90, 10, 80, 120), [80, 119]);
|
|
631
|
+
// header/input rows (outside the composed body) are never clipped
|
|
632
|
+
assert.deepEqual(paneClipCols(clip, 5, 1, 0, 120), [0, 120]);
|
|
633
|
+
assert.deepEqual(paneClipCols(clip, 90, 45, 0, 120), [0, 120]);
|
|
634
|
+
// no split → no clipping
|
|
635
|
+
assert.deepEqual(paneClipCols(null, 5, 10, 0, 120), [0, 120]);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('matchSlash filters commands by prefix and closes on args', () => {
|
|
639
|
+
// bare '/' matches every command
|
|
640
|
+
assert.ok(matchSlash('/').length >= 16, 'bare slash lists all commands');
|
|
641
|
+
// '/h' narrows to /help
|
|
642
|
+
assert.deepEqual(matchSlash('/h').map(c => c.name), ['/help']);
|
|
643
|
+
// '/r' matches commands and aliases starting with r
|
|
644
|
+
const r = matchSlash('/r').map(c => c.name).sort();
|
|
645
|
+
assert.ok(r.includes('/resume'));
|
|
646
|
+
assert.ok(r.includes('/restart'));
|
|
647
|
+
assert.ok(r.includes('/raw'));
|
|
648
|
+
assert.ok(r.includes('/review'));
|
|
649
|
+
// alias prefix match: '/se' → /sessions (alias of /resume)
|
|
650
|
+
assert.deepEqual(matchSlash('/se').map(c => c.name), ['/resume']);
|
|
651
|
+
// '/q' matches /quit via alias
|
|
652
|
+
assert.deepEqual(matchSlash('/q').map(c => c.name), ['/quit']);
|
|
653
|
+
// '/l' includes login/logout-cloud; '/auth' is login's alias
|
|
654
|
+
assert.deepEqual(matchSlash('/l').map(c => c.name), ['/login', '/logout-cloud']);
|
|
655
|
+
assert.deepEqual(matchSlash('/auth').map(c => c.name), ['/login']);
|
|
656
|
+
assert.deepEqual(matchSlash('/account').map(c => c.name), ['/account']);
|
|
657
|
+
assert.deepEqual(matchSlash('/cloud').map(c => c.name), ['/account']);
|
|
658
|
+
assert.deepEqual(matchSlash('/usa').map(c => c.name), ['/usage']);
|
|
659
|
+
// unknown prefix → empty
|
|
660
|
+
assert.deepEqual(matchSlash('/xyz'), []);
|
|
661
|
+
// a space (args mode) closes the popup
|
|
662
|
+
assert.deepEqual(matchSlash('/help '), []);
|
|
663
|
+
assert.deepEqual(matchSlash('/raw foo'), []);
|
|
664
|
+
// non-slash buffer → empty
|
|
665
|
+
assert.deepEqual(matchSlash('hello'), []);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('slashCardDimensions left justifies the commands card aligned with prompt area', () => {
|
|
669
|
+
const geom80 = slashCardDimensions(80);
|
|
670
|
+
assert.equal(geom80.x0, 2, 'x0 is left-justified to 2 on standard 80-col terminal');
|
|
671
|
+
assert.equal(geom80.cardW, 76);
|
|
672
|
+
assert.equal(geom80.innerW, 74);
|
|
673
|
+
|
|
674
|
+
const geom120 = slashCardDimensions(120);
|
|
675
|
+
assert.equal(geom120.x0, 2, 'x0 remains left-justified on wide terminal');
|
|
676
|
+
assert.equal(geom120.cardW, 88, 'card width expands to comfortable cap of 88');
|
|
677
|
+
assert.equal(geom120.innerW, 86);
|
|
678
|
+
|
|
679
|
+
const geomTiny = slashCardDimensions(18);
|
|
680
|
+
assert.ok(geomTiny.x0 <= 2);
|
|
681
|
+
assert.equal(geomTiny.cardW, 16);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('renderSlashCard contains all rows strictly within bounding box without overflow', () => {
|
|
685
|
+
const allCommands = matchSlash('/');
|
|
686
|
+
const widths = [40, 64, 80, 100, 120];
|
|
687
|
+
|
|
688
|
+
for (const W of widths) {
|
|
689
|
+
const { card, cardW } = renderSlashCard(allCommands, 0, W);
|
|
690
|
+
for (let i = 0; i < card.length; i++) {
|
|
691
|
+
const line = card[i];
|
|
692
|
+
const stripped = strip(line);
|
|
693
|
+
assert.equal(
|
|
694
|
+
width(line),
|
|
695
|
+
cardW,
|
|
696
|
+
`Row ${i} width (${width(line)}) must exactly match cardW (${cardW}) for W=${W}`
|
|
697
|
+
);
|
|
698
|
+
assert.ok(
|
|
699
|
+
stripped.startsWith('╭') || stripped.startsWith('│') || stripped.startsWith('╰'),
|
|
700
|
+
`Row ${i} should start with box border`
|
|
701
|
+
);
|
|
702
|
+
assert.ok(
|
|
703
|
+
stripped.endsWith('╮') || stripped.endsWith('│') || stripped.endsWith('╯'),
|
|
704
|
+
`Row ${i} should end with box border`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ------------------------------------------------------------------ glyph tier
|
|
711
|
+
|
|
712
|
+
test('detectGlyphMode honors explicit config and stays conservative on auto', () => {
|
|
713
|
+
assert.equal(detectGlyphMode({ CARTHAGENT_GLYPHS: 'unicode' }, 'linux'), 'unicode');
|
|
714
|
+
assert.equal(detectGlyphMode({ CARTHAGENT_GLYPHS: 'ascii' }, 'linux'), 'ascii');
|
|
715
|
+
assert.equal(detectGlyphMode({ CARTHAGENT_ASCII: '1' }, 'linux'), 'ascii');
|
|
716
|
+
assert.equal(detectGlyphMode({ CARTHAGENT_ASCII: '0' }, 'linux'), 'unicode');
|
|
717
|
+
// Non-UTF-8 locale or dumb terminal can never carry the symbol set.
|
|
718
|
+
assert.equal(detectGlyphMode({ LC_ALL: 'C' }, 'linux'), 'ascii');
|
|
719
|
+
assert.equal(detectGlyphMode({ TERM: 'dumb', LANG: 'en_US.UTF-8' }, 'linux'), 'ascii');
|
|
720
|
+
// Positive evidence of a modern emulator opts in.
|
|
721
|
+
assert.equal(detectGlyphMode({ LANG: 'en_US.UTF-8', WT_SESSION: '1' }, 'win32'), 'unicode');
|
|
722
|
+
assert.equal(detectGlyphMode({ LANG: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: 'truecolor' }, 'linux'), 'unicode');
|
|
723
|
+
// Unknown console with no marker defaults to ASCII so icons never render as `?`.
|
|
724
|
+
assert.equal(detectGlyphMode({ LANG: 'en_US.UTF-8', TERM: 'xterm-256color' }, 'linux'), 'ascii');
|
|
725
|
+
assert.equal(detectGlyphMode({}, 'win32'), 'ascii');
|
|
726
|
+
assert.equal(resolveGlyphs({ mode: 'ascii' }), ASCII_GLYPHS);
|
|
727
|
+
assert.equal(resolveGlyphs({ mode: 'unicode' }), UNICODE_GLYPHS);
|
|
728
|
+
// `auto` and unknown values must detect, not force the Unicode tier.
|
|
729
|
+
assert.equal(resolveGlyphs({ mode: 'auto', env: { LC_ALL: 'C' }, platform: 'linux' }), ASCII_GLYPHS);
|
|
730
|
+
assert.equal(resolveGlyphs({ mode: 'auto', env: { LANG: 'en_US.UTF-8', WT_SESSION: '1' }, platform: 'win32' }), UNICODE_GLYPHS);
|
|
731
|
+
assert.equal(resolveGlyphs({ ascii: true }), ASCII_GLYPHS);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('the ASCII glyph tier is 100% ASCII', () => {
|
|
735
|
+
const pure = s => [...s].every(ch => ch.codePointAt(0) < 128);
|
|
736
|
+
for (const [k, v] of Object.entries(ASCII_GLYPHS)) {
|
|
737
|
+
if (k === 'box') for (const b of Object.values(v)) assert.ok(pure(b), `box.${k} = ${JSON.stringify(b)}`);
|
|
738
|
+
else if (k === 'spinner') for (const f of v) assert.ok(pure(f), `spinner ${JSON.stringify(f)}`);
|
|
739
|
+
else assert.ok(pure(v), `${k} = ${JSON.stringify(v)}`);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('renderD2 ASCII output contains no non-ASCII bytes', () => {
|
|
744
|
+
const g = parseD2(planD2(SAMPLE_PLAN));
|
|
745
|
+
const states = new Map([['goal', 'done'], ['step0', 'done'], ['step1', 'active'], ['verify', 'fail']]);
|
|
746
|
+
const graph = renderD2(g, { width: 44, theme: getTheme('opencode'), states, frame: 2, glyphs: ASCII_GLYPHS }).lines.join('\n');
|
|
747
|
+
assert.ok([...strip(graph)].every(ch => ch.codePointAt(0) < 128), 'ascii graph is pure ASCII');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// ------------------------------------------------------------------ step states
|
|
751
|
+
|
|
752
|
+
const STEP_PLAN = {
|
|
753
|
+
goal: 'Build a task CLI',
|
|
754
|
+
steps: ['slice', 'persist', 'tests'],
|
|
755
|
+
checks: [{ id: 't', kind: 'test', argv: ['node', 'x'], timeoutSeconds: 5 }],
|
|
756
|
+
acceptance: [{ requirement: 'works', checks: ['t'] }],
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
test('computeNodeStates marks exactly one step active while implementing', () => {
|
|
760
|
+
const actives = states => STEP_PLAN.steps.map((_, i) => states.get(`step${i}`)).filter(s => s === 'active');
|
|
761
|
+
// Fresh plan: first step current, the rest pending — never the whole plan.
|
|
762
|
+
const fresh = computeNodeStates({ status: 'implementing', plan: STEP_PLAN, evidence: {}, stepStatus: {} }, false, null, 'h').states;
|
|
763
|
+
assert.equal(actives(fresh).length, 1);
|
|
764
|
+
assert.equal(fresh.get('step0'), 'active');
|
|
765
|
+
assert.equal(fresh.get('step1'), 'pending');
|
|
766
|
+
assert.equal(fresh.get('step2'), 'pending');
|
|
767
|
+
// Explicit done advances the current step.
|
|
768
|
+
const mid = computeNodeStates({ status: 'implementing', plan: STEP_PLAN, evidence: {}, stepStatus: { step0: 'done' } }, false, null, 'h').states;
|
|
769
|
+
assert.equal(actives(mid).length, 1);
|
|
770
|
+
assert.equal(mid.get('step0'), 'done');
|
|
771
|
+
assert.equal(mid.get('step1'), 'active');
|
|
772
|
+
assert.equal(mid.get('step2'), 'pending');
|
|
773
|
+
// An explicit active step wins even if earlier steps were never marked.
|
|
774
|
+
const jumped = computeNodeStates({ status: 'implementing', plan: STEP_PLAN, evidence: {}, stepStatus: { step2: 'active' } }, false, null, 'h').states;
|
|
775
|
+
assert.equal(actives(jumped).length, 1);
|
|
776
|
+
assert.equal(jumped.get('step2'), 'active');
|
|
777
|
+
assert.equal(jumped.get('step0'), 'done');
|
|
778
|
+
assert.equal(jumped.get('step1'), 'done');
|
|
779
|
+
// Finished runs light no step as active.
|
|
780
|
+
const done = computeNodeStates({ status: 'verified', plan: STEP_PLAN, evidence: {}, stepStatus: {} }, false, null, 'h').states;
|
|
781
|
+
assert.equal(actives(done).length, 0);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ------------------------------------------------------------------ scrollbars
|
|
785
|
+
|
|
786
|
+
test('barRange maps a viewport onto the track and hides when content fits', () => {
|
|
787
|
+
assert.equal(barRange(10, 10, 0), null);
|
|
788
|
+
assert.equal(barRange(10, 5, 0), null);
|
|
789
|
+
// Half the content visible → half-height thumb at the top.
|
|
790
|
+
assert.deepEqual(barRange(10, 20, 0), { pos: 0, len: 5 });
|
|
791
|
+
// Scrolled to the bottom → thumb flush with the bottom.
|
|
792
|
+
assert.deepEqual(barRange(10, 20, 10), { pos: 5, len: 5 });
|
|
793
|
+
// A viewport smaller than a cell still yields a one-cell thumb.
|
|
794
|
+
assert.deepEqual(barRange(2, 8, 0), { pos: 0, len: 1 });
|
|
795
|
+
// Out-of-range starts clamp instead of spilling past the track.
|
|
796
|
+
assert.deepEqual(barRange(10, 20, 999), { pos: 5, len: 5 });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('scrollCell uses the theme palette and the resolved glyph tier', () => {
|
|
800
|
+
const t = getTheme('opencode');
|
|
801
|
+
const thumb = scrollCell(UNICODE_GLYPHS, 'thumb', true, t);
|
|
802
|
+
const track = scrollCell(ASCII_GLYPHS, 'track', false, t);
|
|
803
|
+
assert.match(thumb, /\u2503/, 'unicode thumb is the heavy bar');
|
|
804
|
+
assert.match(track, /\|/, 'ascii track is a pipe');
|
|
805
|
+
// Foreground only: the pane paints the surface background, so no baked bg.
|
|
806
|
+
assert.ok(!/\x1b\[48;/.test(thumb) && !/\x1b\[48;/.test(track));
|
|
807
|
+
assert.equal(scrollCell(UNICODE_GLYPHS, null, true, t), ' ', 'no bar when nothing scrolls');
|
|
808
|
+
});
|