Skip to content
Merged
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
13 changes: 13 additions & 0 deletions apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ import {
import type { RuntimeContext } from "./context.ts";
import { keepLatestTaskUpdateResult, registerTaskUpdateCall } from "./task-update-transcript.ts";

/**
* Automatic compaction can run before a provider request inside an active agent loop, after
* `turn_start` already showed the working indicator. Clearing the compaction indicator would
* otherwise leave the streaming response without one until the next `turn_start`.
*/
function restoreWorkingIndicatorAfterCompaction(ctx: RuntimeContext): void {
if (ctx.session?.agent?.state.isStreaming && ctx.workingVisible && ctx.activeStatusIndicator?.kind !== "working") {
ctx.showWorkingStatusIndicator();
}
}

/** Subscribe to the session's agent events. Returns/records the unsubscribe handle (owned by the composition root). */
export function subscribeToAgent(ctx: RuntimeContext): void {
ctx.unsubscribe = ctx.session.subscribe(async (event) => {
Expand Down Expand Up @@ -368,6 +379,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
ctx.autoCompactionEscapeHandler = undefined;
}
ctx.clearStatusIndicator("compaction");
restoreWorkingIndicatorAfterCompaction(ctx);
void ctx.flushCompactionQueue({ willRetry: true });
ctx.redraw.requestRender();
break;
Expand All @@ -380,6 +392,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
ctx.autoCompactionEscapeHandler = undefined;
}
ctx.clearStatusIndicator("compaction");
restoreWorkingIndicatorAfterCompaction(ctx);
if (event.aborted) {
if (event.reason === "manual") {
ctx.showError("Compaction cancelled");
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/test/session-events-working-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,45 @@ test.each(["agent_end", "auto_clm_end", "compaction_end"] as const)(
},
);

test("restores the working indicator when compaction ends inside an active agent run", async () => {
const showWorkingStatusIndicator = vi.fn();
const createContext = (isStreaming: boolean) =>
({
isInitialized: true,
footer: { invalidate: vi.fn() },
settingsManager: { getShowTerminalProgress: () => false },
ui: { terminal: { setProgress: vi.fn() } },
session: { agent: { state: { isStreaming } } },
workingVisible: true,
activeStatusIndicator: undefined,
showWorkingStatusIndicator,
clearStatusIndicator: vi.fn(),
redraw: { requestRender: vi.fn() },
flushCompactionQueue: vi.fn(),
}) as unknown as RuntimeContext;
await handleSessionEvent(createContext(true), {
type: "compaction_end",
reason: "threshold",
result: undefined,
aborted: false,
willRetry: false,
});
await handleSessionEvent(createContext(true), {
type: "auto_clm_end",
result: { attempted: true, accepted: false, fallback: false, reason: "no-edit", requests: 1 },
});
expect(showWorkingStatusIndicator).toHaveBeenCalledTimes(2);
// An idle session (pre-prompt compaction or /compact) must not show a working indicator.
await handleSessionEvent(createContext(false), {
type: "compaction_end",
reason: "manual",
result: undefined,
aborted: false,
willRetry: false,
});
expect(showWorkingStatusIndicator).toHaveBeenCalledTimes(2);
});

test("turn tips follow the current goal branch and respect the disabled setting", async () => {
const now = new Date().toISOString();
const goal = { id: "goal-1", sessionId: "session-1", objective: "Ship it", status: "active", createdAt: now, updatedAt: now };
Expand Down
9 changes: 6 additions & 3 deletions docs/compaction-integrity.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ requests: the model's declared output limit is reduced when the actual request
leaves less context headroom. This avoids starving a reasoning-enabled summary
with a smaller fixed cap. The coding-agent and generic harness follow this rule.

`compaction.reserveTokens` still selects the automatic trigger:
`contextTokens > contextWindow - reserveTokens`. Existing configured/default
trigger values are unchanged. The positional reserve argument to `generateSummary`
Under the default `v2` trigger policy, automatic compaction fires at
`contextWindow * triggerRatio`, capped so the next request keeps the provider
safety margin plus up to 8192 output tokens, and never below half the window.
`tokenLimit` and an explicit `compaction.reserveTokens` can only move it
earlier. The `legacy` policy keeps `contextTokens > contextWindow - reserveTokens`.
The positional reserve argument to `generateSummary`
and `generateSummaryWithUsage` remains accepted for caller compatibility but is
unused for output sizing. `branchSummary.reserveTokens` still limits which source
entries are selected; it does not impose a response-token cap.
Expand Down
19 changes: 8 additions & 11 deletions docs/goal-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,24 @@ failure leaves the prior limit authoritative. `/goal status` shows spent tokens
even when the goal has no limit, for example `Tokens: 123 (unbounded)`.

The exact arguments `stop`, `off`, `reset`, `none`, and `cancel` are aliases for
`clear`, matching Claude Code. Longer text such as `/goal stop flaky tests from
`clear`. Longer text such as `/goal stop flaky tests from
failing` is still an objective. If a pause persists but the host cannot interrupt
the continuation, the paused footer remains visible and the command reports the
interruption failure instead of claiming the run stopped.

## Claude Code comparison
## Completion decisions and follow-ups

StepCode uses explicit `update_goal` tool results from the working agent for
completion and blocker decisions. It has no separate completion evaluator.
[Claude Code's documented goal behavior](https://code.claude.com/docs/en/goal)
uses a small model to evaluate the conversation after each turn and record a
verdict and reason. Adding that evaluator is a separate follow-up: it needs a
provider/model selection policy, evidence inputs, token accounting, and a stop
policy when evaluation fails or no progress is made.
An evaluator that judges the conversation after each turn and records a verdict
and reason is a separate follow-up: it needs a provider/model selection policy,
evidence inputs, token accounting, and a stop policy when evaluation fails or no
progress is made.

StepCode also preserves counters on resume and rejects replacement of an
unfinished goal. Claude Code documents resetting usage baselines on resume and
replacing an active goal with a new condition. These are product differences,
not behaviors introduced by the command aliases. Goal budgets count uncached
unfinished goal. Goal budgets count uncached
input plus output and gate subsequent continuations; an in-flight model request
can exceed the remaining limit.

See [workflow and cron lifecycle](orchestration-lifecycle.md) for the neighboring
coordination primitives and remaining Claude Code differences.
coordination primitives and remaining follow-ups.
14 changes: 4 additions & 10 deletions docs/orchestration-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,7 @@ drains at `agent_settled`, after retry and compaction recovery. Due work waits
while the current turn runs; missed intervals do not create a burst of catch-up
turns.

Offsets follow the documented Claude Code bounds and are derived from the task
ID by default:
Offsets are derived from the task ID by default:

| Task | Offset from the matching local-time minute |
| --- | --- |
Expand Down Expand Up @@ -347,20 +346,15 @@ If the process crashes or a disk write fails after handoff, the stored occurrenc
can be delivered again on recovery. Atomic exactly-once execution would require
an acknowledged outbox/consumer protocol across that boundary.

## Remaining Claude Code differences
## Follow-ups

The comparison uses official documentation retrieved on 2026-09-20:
[workflows](https://code.claude.com/docs/en/workflows),
[goals](https://code.claude.com/docs/en/goal), and
[scheduled tasks](https://code.claude.com/docs/en/scheduled-tasks).

| Area | Current StepCode behavior | Follow-up for closer alignment |
| Area | Current StepCode behavior | Follow-up |
| --- | --- | --- |
| Workflow entry | `/ultracode` and `/ultraloop` share session controls; `ultracode: <task>` and `ultraloop: <task>` opt in for one turn | — |
| Workflow execution | Foreground tool with live agent counts/tasks, journals, cancellation, replay | Background workflow task view with pause/resume controls |
| Goal completion | Working agent calls `update_goal`; user pause/resume and persisted budgets | Independent completion evaluator with visible verdict/reason and a no-progress stop policy |
| Interval scheduling | `cron_create`, `cron_list`, `cron_delete`, `/cron` | `/loop` convenience command, including a design for completion-relative intervals |
| Scheduling persistence | Optional shared project store | Claude Code session schedules are session-local; retain the stronger StepCode persistence contract explicitly |
| Scheduling persistence | Optional shared project store | Keep the project-store persistence contract explicit |

See [session goal lifecycle](goal-lifecycle.md) for goal controls, budget recovery,
and cancellation semantics. The prior
Expand Down
5 changes: 2 additions & 3 deletions docs/plan-loop-cron-workflow-hoh.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,11 @@ telemetry 和 evidence 在进程内串行 append;读取时容忍被杀进程

| 决策 | 选择 | 原因 |
|---|---|---|
| Loop 工具名 | `schedule_wakeup` / `list_wakeups` / `cancel_wakeup` | 与 Claude Code 语料一致,表达 session continuation |
| Loop 工具名 | `schedule_wakeup` / `list_wakeups` / `cancel_wakeup` | 名称直接表达 session continuation |
| Loop 时钟 | 标准 timer + 注入 clock | 零运行时依赖,测试可控 |
| Cron 表达式 | 五字段、项目本地时区 | 足够覆盖 agent 场景,避免秒级复杂度 |
| Cron durable 位置 | `.stepcode/cron/tasks.json` | 项目边界清晰、可备份、可迁移 |
| Workflow DSL | JavaScript | 能表达控制流且接近 Claude Code Workflow 心智 |
| Workflow DSL | JavaScript | 能直接表达控制流,模型和用户都熟悉 |
| Workflow 隔离 | `isolated-vm` 或等效强隔离 | 禁止把 `node:vm` 当安全边界 |
| schema 校验 | TypeBox JSON Schema engine | VM 只传 JSON,错误可解释且不重复实现 validator |
| schema retry | 默认最多三次,可下调 | 防止无界 token 消耗 |
Expand Down Expand Up @@ -422,7 +422,6 @@ telemetry 和 evidence 在进程内串行 append;读取时容忍被杀进程

## 参考

- Claude Code:`ScheduleWakeup`、`CronCreate`/`CronDelete`/`CronList`、Workflow DSL。
- Yan et al., *Harness-of-Harness: Multi-Day Autonomous Software Development with
Continual Improvement*, arXiv:2609.01481 (2026-09-01)。本文借鉴其 planner →
developer → independent QA、evidence carry-over、single-writer、版本化 artifact
Expand Down
7 changes: 0 additions & 7 deletions docs/recent-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,3 @@ Successful interactive selections (Enter, exact `/model provider/id`, and model
History is stored as the global settings field `recentModels`, using Step's existing `config.toml` storage adapter (or the embedded host's settings storage). It is global-only, is validated when read, and concurrent sessions merge selections under the existing storage lock. No credentials or model objects are stored. Recent history does not change startup defaults or model cycling order. With `STEPCODE_CONFIG_PATH`, default selection still writes the external active model; history uses the wrapped global settings storage.

The selector only receives history references. Settings persistence stays in `packages/coding-agent`; `packages/tui` is unchanged. Unavailable references are hidden rather than deleting offline history. Scoped mode filters Recent to scoped models and preserves the order of the remaining scoped models.

## Reference

Behavior was checked against OpenCode v1.2.0, not copied into StepCode:

- [Model dialog](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx): Recent section, unavailable-model filtering, search without duplicated rows.
- [Local model state](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/context/local.tsx): provider/model identity, move-to-front history, ten-entry limit.
Original file line number Diff line number Diff line change
Expand Up @@ -98,38 +98,19 @@ assert: quadrupling the child does not move the number. With
`--include-live-events` the same 40-turn run writes 0.010 MiB, of which 0.005 MiB
is frames, and no frame carries a transcript.

## 4. Competitor survey

Six implementations, source-read. Versions: opencode `f669c46c`, codex `970111f6`
(Rust under `codex-rs/`), kimi-code `743f14097`, minimax-code `da22f11`,
deepseek-harness, ZCode.

| implementation | where the decision lives | deltas persisted | progress persisted | persistence trigger | child history |
| --- | --- | --- | --- | --- | --- |
| opencode | **event type**: `durable` / `ephemeral` are mutually exclusive | no | no | `text.ended`, `tool.success`, `step.ended` | own session; parent keeps sessionID + final text |
| codex | **storage boundary**: `rollout/policy.rs` transient list | no | no | `ItemCompleted`, full `ResponseItem` | own thread; parent keeps agent_thread_id |
| kimi-code | **event type**: static `durable` flag, default false | no | no | durable event; content parts at step end | own agent log; parent keeps resultSummary |
| minimax-code | **transport**: `VOLATILE_EVENT_TYPES` | no | no | `message_end`, `tool_execution_end` | own child session; parent keeps a report |
| deepseek-harness | **event vocabulary**: no progress event type exists | no | no | turn/step boundaries, 200ms batches | own session; parent keeps childId + label |
| ZCode | **event type set**: `TRANSIENT_SESSION_EVENT_TYPES` | no | no | pending/running/completed upserts | own session; parent keeps childSessionId + outputPath |

What all six share:

1. **A non-durable event flows to the UI and not to storage.** codex's
`SubAgentActivity` is durable only when `kind == Completed`, so `Started`,
`Interacted` and `Interrupted` never reach the rollout; opencode's
`session.tool.progress` is `Event.ephemeral`, whose type has no durable
envelope at all.
2. **The successor is the replayable value.** `message_end` / `tool_execution_end`
/ `Text.Ended` carry the authoritative payload; a reader can skip every frame.
3. **Capability, not throttling, is what makes them more live.** Because a frame
is never written, emitting one per token is free — there is no trade between
latency and log size to make.
4. **The child's transcript has its own home**, referenced by id.

One structural difference matters for us: in all six the log file *is* their
storage layer, so filtering at the storage boundary works. Our `stepcode.jsonl`
is a **redirected stdout**, and the session file itself only ever received
## 4. Where the classification belongs

The usual answer to this problem is a durability split: an event either flows
to live consumers only, or it is a durable fact. Deltas and progress frames are
never persisted; the successor event (`message_end`, `tool_execution_end`)
carries the replayable value, so a reader can skip every frame. Once a frame is
never written, emitting one per token is free — capability, not throttling, is
what makes the stream live. A child's transcript has its own home and is
referenced by id.

One structural detail matters for us. When the log file *is* the storage layer,
filtering at the storage boundary is enough. Our `stepcode.jsonl` is a
**redirected stdout**, and the session file itself only ever received
`message_end` — it was never the problem. So the classification has to be applied
by the serialized transports, not by the session store.

Expand Down Expand Up @@ -160,8 +141,7 @@ export function sessionEventDurability(event): "durable" | "ephemeral" {

It is an allowlist of *frames*, so an event added later is durable by default:
forgetting to classify cannot silently opt a new event into a log. The criterion
is "payload is a cumulative snapshot of in-flight work", the same boundary
opencode draws at `Text.Ended`.
is "payload is a cumulative snapshot of in-flight work".

The classification is applied in `modes/json-event.ts`, and nowhere else.
`toJsonEvent(event, { includeLiveEvents })` returns `undefined` for a frame the
Expand Down Expand Up @@ -345,7 +325,7 @@ the revert — read the child session instead.
1. **Progress does not yet have a channel of its own.** It is classified as
non-durable and excluded from the log, which removes the amplification, but it
still travels as a session event. The target design gives live progress a
dedicated surface — the shape codex and opencode converge on — so that
dedicated surface so that
`omittedResults`, the roster byte budget and `rosterFields` can be deleted
outright. **Removal condition:** when a live frame no longer carries a roster,
all three go, and no test needs them.
Expand All @@ -354,8 +334,7 @@ the revert — read the child session instead.
clarity only, at the cost of a new type plus every consumer. Prefer to revisit
it together with (1).
3. **Session-file compaction.** After this change growth is linear, not quadratic,
but a long session is still large. Competitors have zstd or a file cap; we do
not.
but a long session is still large; there is no compression or file cap yet.
4. **Full backpressure.** One frame in flight bounds the loop's queue, but
`output-guard`'s stdout queue has no hard limit. With facts only, that traffic
is small, so this is secondary.
Expand All @@ -368,4 +347,5 @@ the revert — read the child session instead.
boundary.
6. **Type-level durability.** The classification is enforced in one projection
module, not by the event type, and a check script fails the build if a second
policy appears. opencode makes it a compile-time property; we do not yet.
policy appears. Making it a compile-time property of the event type is not
done yet.
6 changes: 3 additions & 3 deletions docs/step-unified-config-and-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,10 @@ if (ctx.mode !== "tui") await startup; // 仅 TUI 分离
**解决**:先 `resolve()` 成绝对路径再取 `dirname()`;若已经是文件系统根,则退回
agent 目录本身,而不是把凭据写到宿主指定的命名空间之外。

### 4.4 OAuth 借用了 Codex 的应用身份
### 4.4 OAuth 借用了第三方应用身份

早期实现硬编码了 Codex 的 client id,导致授权页面显示 "Figma MCP in Codex"。该硬编码
已移除,**不得恢复或借用 Claude / Codex 的应用身份**。
早期实现硬编码了其他客户端的 OAuth client id,导致授权页面显示的应用不是 Step。该硬编码
已移除,**不得恢复或借用其他产品的应用身份**。

官方 Figma MCP 对 Step 返回 403,现在会给出明确指引:

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ const stream = agentLoop(

`shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. The `AgentOptions` callback also receives the active run's `AbortSignal` as its second argument.

To change the context right before a provider request instead of stopping, set `beforeModelRequest` (on `AgentLoopConfig`, `AgentOptions`, or the `Agent` instance). It runs before every provider request: `phase: "initial"` for the first request of a prompt or `continue()`, and `phase: "follow-up"` for each later request. The context it receives already contains the prompts, completed tool results, and steering injected for that request. Return `{ context, model, thinkingLevel }` replacements, or `undefined` to send the context unchanged. Tool-call markup resampling of the same request does not call it again. On follow-up requests that have not received steering yet, the loop polls steering once more after the hook, so input queued during a long hook such as compaction reaches the same request. `prepareNextTurn` keeps its existing timing: after `turn_end`, before steering is polled.

When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.

### continue() Event Sequence
Expand Down
Loading
Loading