Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added a model row to the `/workflow connect` graph node cards. Each stage card now shows the running stage's model, thinking level, and Codex `fast` tier (provider prefix dropped and thinking omitted when off to fit the ~22-cell card; the fast marker is kept over the thinking level when space is tight), on a dedicated row beneath the status line while keeping the existing duration, status, and dependency (`root`/`N deps`) fields. The row reflects live model fallbacks — when a stage falls back to another model it updates to the model actually running. Node card height grows from 5 to 6 rows. The effective thinking level is carried on the run snapshot (`StageSnapshot.thinkingLevel`) alongside the existing `model` field and persisted through DBOS durability so resumed runs (`/workflow resume`) restore the same model + thinking identity.

## [0.9.13-alpha.1] - 2026-08-05

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface StageDraft {
readonly endedAt?: number;
readonly durationMs?: number;
readonly model?: string;
readonly thinkingLevel?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: readonly string[];
readonly modelAttempts?: DurableStageCheckpoint["modelAttempts"];
Expand Down Expand Up @@ -245,6 +246,7 @@ export function mergeStageDraft(
...valueOrExisting("endedAt", checkpoint, existing),
...valueOrExisting("durationMs", checkpoint, existing),
...valueOrExisting("model", checkpoint, existing),
...valueOrExisting("thinkingLevel", checkpoint, existing),
...valueOrExisting("fastMode", checkpoint, existing),
...valueOrExisting("attemptedModels", checkpoint, existing),
...valueOrExisting("modelAttempts", checkpoint, existing),
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/durable/completed-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ function stageSnapshotFromDraft(draft: StageDraft, id: string, parentIds: readon
...(draft.sessionId !== undefined ? { sessionId: draft.sessionId } : {}),
...(draft.sessionFile !== undefined ? { sessionFile: draft.sessionFile } : {}),
...(draft.model !== undefined ? { model: draft.model } : {}),
...(draft.thinkingLevel !== undefined ? { thinkingLevel: draft.thinkingLevel } : {}),
...(draft.fastMode !== undefined ? { fastMode: draft.fastMode } : {}),
...(draft.attemptedModels !== undefined ? { attemptedModels: draft.attemptedModels } : {}),
...(draft.modelAttempts !== undefined ? { modelAttempts: draft.modelAttempts } : {}),
Expand Down
4 changes: 4 additions & 0 deletions packages/workflows/src/durable/dbos-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface DbosCheckpointEnvelope extends WorkflowSerializableObject {
readonly durationMs?: number;
readonly result?: string;
readonly model?: string;
readonly thinkingLevel?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: WorkflowSerializableValue;
readonly modelAttempts?: WorkflowSerializableValue;
Expand Down Expand Up @@ -156,6 +157,7 @@ export function encodeCheckpoint(checkpoint: DurableCheckpoint): DbosCheckpointE
...(s.durationMs !== undefined ? { durationMs: s.durationMs } : {}),
...(s.result !== undefined ? { result: s.result } : {}),
...(s.model !== undefined ? { model: s.model } : {}),
...(s.thinkingLevel !== undefined ? { thinkingLevel: s.thinkingLevel } : {}),
...(s.fastMode !== undefined ? { fastMode: s.fastMode } : {}),
...(s.attemptedModels !== undefined ? { attemptedModels: [...s.attemptedModels] } : {}),
...(s.modelAttempts !== undefined ? { modelAttempts: s.modelAttempts as WorkflowSerializableValue } : {}),
Expand Down Expand Up @@ -275,6 +277,7 @@ function decodeEnvelope(workflowId: string, env: DbosCheckpointEnvelope): Durabl
!isOptionalFiniteNumber(env.durationMs) ||
(env.result !== undefined && typeof env.result !== "string") ||
(env.model !== undefined && typeof env.model !== "string") ||
(env.thinkingLevel !== undefined && typeof env.thinkingLevel !== "string") ||
(env.fastMode !== undefined && typeof env.fastMode !== "boolean") ||
(env.attemptedModels !== undefined && !isStringArray(env.attemptedModels)) ||
(env.modelAttempts !== undefined && !isModelAttempts(env.modelAttempts))
Expand All @@ -294,6 +297,7 @@ function decodeEnvelope(workflowId: string, env: DbosCheckpointEnvelope): Durabl
...(typeof env.durationMs === "number" ? { durationMs: env.durationMs } : {}),
...(typeof env.result === "string" ? { result: env.result } : {}),
...(typeof env.model === "string" ? { model: env.model } : {}),
...(typeof env.thinkingLevel === "string" ? { thinkingLevel: env.thinkingLevel } : {}),
...(typeof env.fastMode === "boolean" ? { fastMode: env.fastMode } : {}),
...(isStringArray(env.attemptedModels) ? { attemptedModels: env.attemptedModels } : {}),
...(Array.isArray(env.modelAttempts)
Expand Down
3 changes: 3 additions & 0 deletions packages/workflows/src/durable/stage-primitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ function taskCheckpointMetadata(result: WorkflowTaskResult): Partial<DurableStag
...(result.sessionId !== undefined ? { sessionId: result.sessionId } : {}),
...(result.sessionFile !== undefined ? { sessionFile: result.sessionFile } : {}),
...(result.model !== undefined ? { model: result.model } : {}),
...(result.thinkingLevel !== undefined ? { thinkingLevel: result.thinkingLevel } : {}),
...(result.fastMode !== undefined ? { fastMode: result.fastMode } : {}),
...(result.attemptedModels !== undefined ? { attemptedModels: [...result.attemptedModels] } : {}),
...(result.modelAttempts !== undefined ? { modelAttempts: [...result.modelAttempts] } : {}),
Expand Down Expand Up @@ -367,6 +368,7 @@ function mergeCheckpointHydrationMetadata(
...(replayValueCheckpoint.sessionId === undefined ? metadataValue(checkpoints, "sessionId") : {}),
...(replayValueCheckpoint.sessionFile === undefined ? metadataValue(checkpoints, "sessionFile") : {}),
...(replayValueCheckpoint.model === undefined ? metadataValue(checkpoints, "model") : {}),
...(replayValueCheckpoint.thinkingLevel === undefined ? metadataValue(checkpoints, "thinkingLevel") : {}),
...(replayValueCheckpoint.fastMode === undefined ? metadataValue(checkpoints, "fastMode") : {}),
...(replayValueCheckpoint.attemptedModels === undefined ? metadataValue(checkpoints, "attemptedModels") : {}),
...(replayValueCheckpoint.modelAttempts === undefined ? metadataValue(checkpoints, "modelAttempts") : {}),
Expand Down Expand Up @@ -478,6 +480,7 @@ export function recordCachedStageIntoStore(
...(checkpoint?.sessionId !== undefined ? { sessionId: checkpoint.sessionId } : {}),
...(checkpoint?.sessionFile !== undefined ? { sessionFile: checkpoint.sessionFile } : {}),
...(checkpoint?.model !== undefined ? { model: checkpoint.model } : {}),
...(checkpoint?.thinkingLevel !== undefined ? { thinkingLevel: checkpoint.thinkingLevel } : {}),
...(checkpoint?.fastMode !== undefined ? { fastMode: checkpoint.fastMode } : {}),
...(checkpoint?.attemptedModels !== undefined ? { attemptedModels: checkpoint.attemptedModels } : {}),
...(checkpoint?.modelAttempts !== undefined ? { modelAttempts: checkpoint.modelAttempts } : {}),
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/durable/stage-topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function durableStageCheckpointMetadata(
...(stage.sessionFile !== undefined ? { sessionFile: stage.sessionFile } : {}),
...(stage.durationMs !== undefined ? { durationMs: stage.durationMs } : {}),
...(stage.model !== undefined ? { model: stage.model } : {}),
...(stage.thinkingLevel !== undefined ? { thinkingLevel: stage.thinkingLevel } : {}),
...(stage.fastMode !== undefined ? { fastMode: stage.fastMode } : {}),
...(stage.attemptedModels !== undefined ? { attemptedModels: [...stage.attemptedModels] } : {}),
...(stage.modelAttempts !== undefined ? { modelAttempts: [...stage.modelAttempts] } : {}),
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/durable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export interface DurableStageCheckpoint {
readonly result?: string;
/** Completed stage/task model metadata used to hydrate replayed snapshots. */
readonly model?: string;
readonly thinkingLevel?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: readonly string[];
readonly modelAttempts?: readonly WorkflowModelAttempt[];
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/engine/primitives/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function createTaskPrimitive(runtime: EngineRuntime): WorkflowTaskPrimitive {
...(sessionId !== undefined ? { sessionId } : {}),
...(stage.sessionFile !== undefined ? { sessionFile: stage.sessionFile } : {}),
...(stageMeta.model !== undefined ? { model: stageMeta.model } : {}),
...(stageMeta.thinkingLevel !== undefined ? { thinkingLevel: stageMeta.thinkingLevel } : {}),
...(stageMeta.fastMode === true ? { fastMode: stageMeta.fastMode } : {}),
...(stageMeta.attemptedModels !== undefined ? { attemptedModels: stageMeta.attemptedModels } : {}),
...(stageMeta.modelAttempts !== undefined ? { modelAttempts: stageMeta.modelAttempts } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export function createWorkflowStageFactory(input: {
...(replaySource.result !== undefined ? { result: replaySource.result } : {}),
...(replaySource.sessionId !== undefined ? { sessionId: replaySource.sessionId } : {}),
...(replaySource.sessionFile !== undefined ? { sessionFile: replaySource.sessionFile } : {}),
...(replaySource.model !== undefined ? { model: replaySource.model } : {}),
...(replaySource.thinkingLevel !== undefined ? { thinkingLevel: replaySource.thinkingLevel } : {}),
...(replaySource.fastMode !== undefined ? { fastMode: replaySource.fastMode } : {}),
replayedFromStageId: replaySource.id,
replayed: true,
}
Expand Down Expand Up @@ -143,6 +146,8 @@ export function createWorkflowStageFactory(input: {

const applyModelFallbackMeta = (meta: ReturnType<InternalStageContext["__modelFallbackMeta"]>): void => {
if (meta.model !== undefined) stageSnapshot.model = meta.model;
if (meta.thinkingLevel !== undefined) stageSnapshot.thinkingLevel = meta.thinkingLevel;
else delete stageSnapshot.thinkingLevel;
if (meta.fastMode !== undefined) {
if (meta.fastMode) stageSnapshot.fastMode = true;
else delete stageSnapshot.fastMode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function createReplayStageContext(input: {
return replaySource.model as never;
},
get thinkingLevel() {
return undefined as never;
return replaySource.thinkingLevel as never;
},
get messages() {
return [] as never;
Expand All @@ -144,6 +144,7 @@ export function createReplayStageContext(input: {
__pendingMessageCount: () => 0,
__modelFallbackMeta: () => ({
...(replaySource.model !== undefined ? { model: replaySource.model } : {}),
...(replaySource.thinkingLevel !== undefined ? { thinkingLevel: replaySource.thinkingLevel } : {}),
...(replaySource.fastMode === true ? { fastMode: replaySource.fastMode } : {}),
...(replaySource.attemptedModels !== undefined ? { attemptedModels: replaySource.attemptedModels } : {}),
...(replaySource.modelAttempts !== undefined ? { modelAttempts: replaySource.modelAttempts } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,10 @@ export class StageSessionController {
const attemptedModels = this.modelAttempts.map((attempt) => attempt.model);
const model = this.selectedModel ?? workflowModelId(this.session?.model);
const fastMode = this.isWorkflowFastModeEnabled();
const thinkingLevel = this.session?.thinkingLevel ?? this.pendingThinkingLevel;
return {
...(model !== undefined ? { model } : {}),
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
...(attemptedModels.length > 0 ? { attemptedModels } : {}),
...(this.modelAttempts.length > 0 ? { modelAttempts: [...this.modelAttempts] } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface AgentSessionAdapter {

export interface StageModelFallbackMeta {
readonly model?: string;
readonly thinkingLevel?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: readonly string[];
readonly modelAttempts?: readonly WorkflowModelAttempt[];
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/src/shared/authoring-contract-stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export interface WorkflowTaskResult extends WorkflowTaskContext {
readonly sessionFile?: string;
readonly artifacts?: readonly WorkflowArtifact[];
readonly model?: string;
readonly thinkingLevel?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: readonly string[];
readonly modelAttempts?: readonly WorkflowModelAttempt[];
Expand Down
8 changes: 8 additions & 0 deletions packages/workflows/src/shared/store-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ export interface StageSnapshot {
sessionFile?: string;
/** Effective model id selected for this stage after fallback resolution. */
model?: string;
/**
* Effective reasoning/thinking level in force for this stage's session
* (e.g. "off", "low", "high"). Populated on the live snapshot alongside
* {@link model} so background-run surfaces can show the same model +
* thinking identity the main session footer shows. Optional: absent for
* restored runs and stages whose model has no reasoning control.
*/
thinkingLevel?: string;
/** True when Codex fast mode applied to this workflow stage. */
fastMode?: boolean;
/** Ordered model ids attempted by fallback orchestration. */
Expand Down
16 changes: 16 additions & 0 deletions packages/workflows/src/tui/codex-fast-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Footer-parity Codex fast-tier label suffix.
*
* Mirrors `@bastani/atomic`'s `formatCodexFastModeModelLabel` (`<label> fast`
* when the fast/priority tier applies) but is re-implemented locally so the
* workflow TUI never imports the heavy `@bastani/atomic` package barrel into
* its module graph. Importing that barrel here pulls the whole coding-agent
* index — which fails to evaluate under the `pi-tui`-mocked overlay test
* subprocesses (see overlay-adapter-hidden-render / -autowrap tests) with
* "Export named 'formatCodexFastModeModelLabel' not found".
*
* cross-ref: packages/coding-agent/src/core/codex-fast-mode.ts
*/
export function codexFastModeLabel(label: string, enabled: boolean): string {
return enabled ? `${label} fast` : label;
}
2 changes: 1 addition & 1 deletion packages/workflows/src/tui/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import type { StageSnapshot } from "../shared/store-types.js";

export const NODE_W = 24;
export const NODE_H = 5;
export const NODE_H = 6;

export interface LayoutNode {
stage: StageSnapshot;
Expand Down
30 changes: 28 additions & 2 deletions packages/workflows/src/tui/node-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import type { StageSnapshot, StageStatus } from "../shared/store-types.js";
import { elapsedStageMs } from "../shared/timing.js";
import { codexFastModeLabel } from "./codex-fast-label.js";
import { BOLD, hexBg, hexToAnsi, lerpColor, paint, RESET } from "./color-utils.js";
import type { GraphTheme } from "./graph-theme.js";
import { NODE_H, NODE_W } from "./layout.js";
Expand Down Expand Up @@ -129,7 +130,30 @@ function metaText(stage: StageSnapshot): string {
if (stage.topologyState === "unavailable") return "topology unavailable";
const deps = stage.parentIds.length;
const dependencyText = deps === 0 ? "root" : deps === 1 ? "1 dep" : `${deps} deps`;
return stage.fastMode === true ? `${dependencyText} · fast` : dependencyText;
return dependencyText;
}

/**
* Compact model label for the card's dedicated model row (~22 cells): the
* provider prefix is dropped, the thinking level is appended when set (omitted
* when off), and the Codex fast tier is appended via the shared footer helper.
* The thinking level and the ` fast` marker are load-bearing, so on overflow
* the model name is truncated first and both suffixes are always kept whole.
* `—` when no model is resolved yet.
*/
function modelText(stage: StageSnapshot, innerWidth: number): string {
const model = stage.model;
if (model === undefined || model === "") return "—";
const slash = model.lastIndexOf("/");
const short = slash >= 0 ? model.slice(slash + 1) : model;
const level = stage.thinkingLevel;
const showLevel = level !== undefined && level !== "" && level !== "off";
const suffix = codexFastModeLabel(showLevel ? ` · ${level}` : "", stage.fastMode === true);
const full = `${short}${suffix}`;
if (visibleWidth(full) <= innerWidth) return full;
// Truncate the model name first so the thinking level and fast marker survive.
const room = Math.max(1, innerWidth - visibleWidth(suffix));
return `${truncateToWidth(short, room, "…")}${suffix}`;
}

function workflowChildRunRows(stage: StageSnapshot, width: number): string[] {
Expand Down Expand Up @@ -295,6 +319,7 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string

const contentRows = Math.max(0, height - 2);
const metaLine = `${bg}${bc}│${RESET}${centreColored(metaText(stage), innerWidth, theme.dim, bg)}${bg}${bc}│${RESET}`;
const modelLine = `${bg}${bc}│${RESET}${centreColored(modelText(stage, innerWidth), innerWidth, theme.textMuted, bg)}${bg}${bc}│${RESET}`;
const childRunLines = workflowChildRunRows(stage, innerWidth).map(
(row) => `${bg}${bc}│${RESET}${centreColored(row, innerWidth, theme.dim, bg)}${bg}${bc}│${RESET}`,
);
Expand Down Expand Up @@ -323,9 +348,10 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string
`${bg}${bc}│${RESET}` +
centreColored("↵ enter to respond", innerWidth, theme.dim, bg) +
`${bg}${bc}│${RESET}`,
modelLine,
]
: childSummaryLine === undefined
? [durLine, statusLine, metaLine]
? [durLine, statusLine, modelLine, metaLine]
: [...childRunLines, childSummaryLine];

// A queued steer/follow-up is invisible once the user leaves the stage chat,
Expand Down
15 changes: 15 additions & 0 deletions test/unit/durable-dbos-topology.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ describe("current DBOS stage topology", () => {
assert.equal(decodedLegacy.topology, undefined);
});

test("round-trips stage model + thinking-level metadata through the DBOS envelope", () => {
const checkpoint: DurableStageCheckpoint = {
...stage("wf-stage-thinking"),
model: "anthropic/claude-opus-4.8",
thinkingLevel: "high",
fastMode: true,
};
const envelope = encodeCheckpoint(checkpoint);
const decoded = decodeToCheckpoint(checkpoint.workflowId, checkpoint.checkpointId, envelope);
assert.ok(decoded?.kind === "stage");
assert.equal(decoded.model, "anthropic/claude-opus-4.8");
assert.equal(decoded.thinkingLevel, "high");
assert.equal(decoded.fastMode, true);
});

test("rejects a marked current stage envelope with missing topology", () => {
const checkpoint = stage("wf-missing-topology");
const envelope = { ...encodeCheckpoint(checkpoint), topology: undefined };
Expand Down
4 changes: 3 additions & 1 deletion test/unit/durable-stage-frontier-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ test("parallel replay uses fail-fast scope parents instead of flattening fanout"
function cpStageId(name: string): string {
return `checkpoint-only:${name}`;
}
test("cached replay hydrates persisted stage timing, result, session, and model metadata", () => {
test("cached replay hydrates persisted stage timing, result, session, and model + thinking metadata", () => {
const store = createStore();
store.recordRunStart({
id: WORKFLOW_ID,
Expand All @@ -252,6 +252,7 @@ test("cached replay hydrates persisted stage timing, result, session, and model
sessionId: "sid",
sessionFile: "/tmp/session.jsonl",
model: "gpt-test",
thinkingLevel: "high",
fastMode: true,
attemptedModels: ["gpt-test"],
modelAttempts: [{ model: "gpt-test", success: true }],
Expand All @@ -266,6 +267,7 @@ test("cached replay hydrates persisted stage timing, result, session, and model
assert.equal(stage.sessionId, "sid");
assert.equal(stage.sessionFile, "/tmp/session.jsonl");
assert.equal(stage.model, "gpt-test");
assert.equal(stage.thinkingLevel, "high");
assert.equal(stage.fastMode, true);
assert.deepEqual(stage.attemptedModels, ["gpt-test"]);
assert.equal(stage.modelAttempts?.[0]?.success, true);
Expand Down
Loading