Pi rejects plain prompt commands while streaming unless streamingBehavior
is specified. Send prompts with streamingBehavior=steer so /ps and /btw
supplement an active run and still work across the remote turn-end boundary.
Keep in-flight attachments when a text-only supplement has no message ID.
Add protocol, subprocess, and user-journey regressions with generic fixtures,
and document Pi RPC behavior without changing the shared Send contract.
Constraint: Pi raw steer queues messages without starting an idle agent
Rejected: Core steering interface and raw steer RPC | unnecessary shared API and late-turn delivery gaps
Confidence: high
Scope-risk: narrow
Directive: Keep streamingBehavior inside the Pi adapter and preserve attachments on text-only sends
Tested: Regression failures on unmodified main; go build ./...; go vet ./...; incremental golangci-lint; full Pi race tests; core /ps and /btw race tests; complete CUJ suite
Not-tested: Live provider round trip; broad suites hit existing macOS launchd-status and CUJ A5 TempDir cleanup failures, both reproduced on main 757b4df0
flushPartial sends the flushed partial text from a goroutine, so after
stopUnsolicitedReader returned the next foreground turn's reply could
overtake or interleave with the flushed chunks.
Publish a flush-done channel on interactiveState. The reader closes it
when the flush sender goroutine finishes (or on exit when nothing was
flushed), and stopUnsolicitedReader waits on it within the same
unsolicitedReaderStopTimeout budget it already uses for the reader
exit. On timeout it logs and continues; no Events resync is forced
since the reader has already exited. interactiveMu is never waited on
beyond the existing bound.
Add TestUnsolicitedReader_FlushOrderedBeforeNextTurn (slow platform,
multi-chunk flush, asserts exact send order) and
TestUnsolicitedReader_FlushWaitIsBounded, and update the safety note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a Claude Code session is resumed after the previous process exited
with a background shell task still running, the CLI first replays a task
notification ("Background shell command didn't finish before the previous
session ended") and closes it with a synthetic result: origin.kind ==
"task-notification", num_turns == 0, result == "". That result arrives
after cc-connect has written the user's prompt to stdin, so the engine
finished the user's turn with "(empty response)" and the real reply was
pushed into the unsolicited relay.
- agent/claudecode: drop result events that close an empty, model-less
task-notification turn; they carry nothing to deliver.
- core: when the unsolicited reader is cancelled (or the agent channel
closes) mid-turn, relay the text it already accumulated instead of
silently discarding it.
Refs #1877
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Platforms forward an AskUserQuestion option tap to the engine as a plain
"askq:<q>:<opt>" message. When that tap arrives after the question was
already answered (for example by a chat message), there is no pending
question, so the tap fell through to the normal message path: on a busy
session it was queued ("Message received — will process after the current
task finishes") and later sent to the agent as the literal text
"askq:0:2". A tap while a tool permission was pending got the
allow/deny hint, and a tap on an earlier question's card in a
multi-question prompt answered the current question with the wrong option.
The engine now recognises option taps and, when they do not match the
question being asked, tells the user the question is no longer active
instead of passing the tap on. Free text that merely starts with "askq:"
is unaffected.
When the primary model's safety classifiers decline a Claude Code request,
the CLI re-runs it on a fallback model and keeps the rest of the session
there (claude-opus-5-5 -> claude-opus-4-8 for the cyber category). It
reports this on stream-json as a system event with subtype
model_refusal_fallback, which cc-connect ignored: IM users got answers from
the fallback model with no notice, and the reply footer kept naming the
model from the init event.
- claudecode: parse model_refusal_fallback. For scope "session", or no
scope from older CLIs, move the session's active model to the fallback
model and drop the cached context window so the next result event
reports the fallback model's own. Emit core.EventModelSwitch once per
refused request and target, since the CLI can announce one fallback
twice (provisional, then final).
- core: add EventModelSwitch carrying a ModelSwitch. The interactive and
unsolicited event loops send a localized notice naming both models, the
refusal category and the /model command that switches back.
HandleRelay accumulated every EventToolResult into textParts, and the timeout
path hands textParts back to the caller verbatim. A relay whose target agent
ran past the deadline therefore returned its whole tool log as if it were the
answer: measured at 11932 chars in a single timed-out relay, against 165 to
2034 for turns that finished normally. That blob then sat in the caller's
context and was re-billed on every later turn of its session — the token cost
two /bind-ed bots were paying for nothing.
Tool output is intermediate state, not an answer, so it no longer reaches
textParts. The normal path is unaffected either way: both agent/claudecode and
agent/cursor populate EventResult.Content, so resp is the final answer and
textParts gets discarded.
The timeout path also stops returning a bare truncated fragment as a success.
relayPartialResponseOrError now trims to 2000 runes and wraps the fragment in
an explicit notice (MsgRelayPartial, all five languages), because a silent
fragment is indistinguishable from a real answer — the caller had no way to
tell it had been cut off. It becomes a method on *Engine to reach i18n.
TestHandleRelay_TimeoutExcludesToolResults is the regression test: a tool
result carrying a sentinel is not relayed, assistant text still is.
TestHandleRelay_ReturnsPartialOnTimeout keeps asserting the partial text
survives, now through the notice.
The /list card button routes through executeCardAction instead of cmdSwitch,
and only the former cleared the session history after switching. cmdSwitch
documents why that must not happen: SwitchToAgentSession returns the existing
Session when the agent session ID is known, so wiping History makes /status
and /history report zero messages right after a successful switch. When no
prior session matches, the new Session already has nil History, so leaving it
alone is a no-op.
Reproduced as: /list shows a session with 97 messages, selecting it switches
successfully, then /status reports "messages: 0". Typing "/switch 1" instead of
tapping the row kept the history, so the behaviour depended on how the switch
was invoked.
extractWorkspaceChannelKey rebuilt a workspace binding key from the
sessionKey alone. extractChannelID uses SplitN(key, ":", 4), so a Feishu
thread key "feishu:<chatID>:root:<rootID>" yielded just "<chatID>" and
every thread in a chat collapsed onto one binding. /status then reported
".", /list came back empty, /dir showed another conversation's directory,
and /new resolved an interactive key without the workspace prefix, so
cleanupInteractiveState missed the real state and the agent process leaked.
Platforms own their key layout, and core must not hardcode private markers
such as "root:", so this adds an optional ChannelScopedSessionKey interface
and asks the originating platform first. Feishu implements it by reusing
parseThreadRootID, matching what populateWorkspaceChannelKeys already
derives. Platforms without per-thread scoping implement nothing and keep
the previous behaviour, so this is purely additive: no interface signature
changes, and no other platform needs a line edited.
Two follow-on fixes the same key mismatch exposed:
- sessionContextForKey read the binding directly, but multi-workspace /dir
records a per-interactive-key override rather than rebinding the channel,
and only workspaceContext applies it. Resolving through workspaceContext
keeps /status and /dir consistent with the directory the agent uses.
- The SessionManager is per-workspace (getOrCreateWorkspaceAgent), so topics
sharing a directory shared every session in it. /list offered other
topics' sessions and /switch could resume one. applySessionFilter now
drops sessions owned by another sessionKey while keeping unowned ones,
which are CLI-created sessions that have no conversation to belong to.
applySessionFilter is inert outside multi-workspace mode, and the tests
cover both that and the unclaimed-key fallbacks.
Refs #193, which adds a separate /path command and leaves /dir global.
getOrCreateWorkspaceAgent built the per-workspace session file as
filepath.Join(filepath.Dir(e.sessions.StorePath()), ...). With no store
path configured — which is how every engine in the test suite is built —
filepath.Dir("") is ".", so the path collapsed to a bare relative
"<engine>_ws_<hash>.json" and the session manager wrote it into the
process working directory. That is what littered core/ with test_ws_*.json
during `go test ./core/`.
An empty store path already means "no persistence" everywhere else in the
session manager (saveLocked returns early), so it now means the same here:
the workspace manager is built unpersisted instead of inventing a path.
A configured store path still derives the workspace file beside it, as
before.
Regressions: an engine without a store path yields a workspace manager
whose StorePath() is empty, and a configured one still derives
test_ws_<hash>.json in the store's directory.
Also ignores the stale files contributors already have on disk.
Co-authored-by: Claude Code <noreply@anthropic.com>
robfig/cron accepts a leading CRON_TZ=<zone> (or TZ=<zone>) field, and
cc-connect stores such expressions as-is. Two display bugs followed:
- CronExprToHuman only understood 5 fields, so a CRON_TZ job fell back to
echoing the raw expression instead of "Daily at 15:00".
- renderCronCard and cmdCronList formatted next/last run with the process
local zone, so "CRON_TZ=Asia/Shanghai 0 15 * * *" showed up as 03:00 on
a UTC-4 host with no hint that the job was pinned to another zone.
Strip the timezone field before humanizing, resolve the schedule's
*cron.SpecSchedule location, format next/last run in that location and
append the zone name to the human-readable schedule when it differs from
local time. Plain 5-field expressions render exactly as before.
Regression tests: TestCronExprToHuman_TimezonePrefix and
TestCronDisplay_UsesScheduleTimezone fail on the pre-fix code and pass
after the fix.
Co-authored-by: claw <claw@clawdeMac-mini.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The multi-workspace feature (per-project base_dir + channel-to-directory
routing) was previously only configurable via chat slash commands or by
hand-editing config.toml. Expose it through the management REST API
(GET/PATCH workspace_mode+workspace_base_dir, GET/POST/DELETE
/projects/{name}/workspaces) and a new "Workspaces" tab in the project
detail page, so operators can toggle the mode, set base_dir, and
bind/unbind channels entirely from the web dashboard.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(codex): support GPT-5.6 reasoning levels
* fix(codex): show all reasoning levels in usage
* fix(codex): use upstream dynamic model filter
* test(codex): drop duplicate GPT-5.6 API coverage
The single-select AskUserQuestion card was rendered as a column_set
row per option, with the option description in a weighted column and
the click button in an 'auto' column. On Feishu mobile the button
column was clipped or non-interactive, and even on desktop the
column_set > column > button pattern was reported as unreliable —
Po1nt9's log during the 08:00-08:25 test window showed zero
card-action entries for ask_user_question cards while permission
cards (which use a flat action row, tag:"action") dispatched
normally.
Mirror the permission-card layout: each option becomes a markdown
description followed by a flat CardActions row with a single button.
The askq:qIdx:optIdx value plus askq_label/askq_question extras are
preserved so the Feishu callback handler still renders the
post-answer card. The Note line that already nudges users toward
numeric / text fallbacks stays in place.
Sibling fix to PR #1677 (issue #1659, pi 0.84.0+ tool-call drop) —
both reporters run pi through cc-connect on Feishu.
统一两条时间戳测试注释的空格对齐,保持新增测试文件通过 gofmt。\n\nverify: go test ./core -run TestStaleLockDumpNamePadding\|TestPruneStaleLockDumps\|TestPruneStaleLockDumpsNoOps\|TestDumpStaleLockStacksEndToEnd\|TestSession_TryLockUnlock\|TestSession_Busy\$\|TestSession_LateUnlockDropped\|TestSession_BreakStaleLockThreshold\|TestSession_ForceUnlock -count=1 (ok); go vet ./core ./cmd/... ./agent/claudecode (ok); git diff --check (ok)
DirHistory's exported methods (Add, List, Get, Previous, Contains,
SetMaxSize) had no direct tests; engine tests only used NewDirHistory
as setup. The methods carry non-trivial logic — move-to-front dedup,
maxSize trimming, 1-based Get with bounds check, Previous at index 2,
defensive-copy List, JSON persistence across instances — so a
regression in any of them would have gone unnoticed.
Add table-driven tests covering each behavior plus the corrupt-file
load path.
Co-authored-by: Claude <noreply@anthropic.com>
TestQueuedMessagePreservesFiles returned as soon as the queued prompt reached
the agent, while the still-running turn goroutine persisted the session store
under t.TempDir(). TestHandleCronExec_TriggersJob returned as soon as the cron
run's messages were sent, before the background goroutine's store.MarkRun save.
Both writes raced t.TempDir RemoveAll ("directory not empty"). Wait for the
queued turn's reply / for MarkRun to be persisted before returning. Test-side
only; assertions unchanged.
Co-authored-by: wen_guoxing <wen_guoxing@itrus.com.cn>
Co-authored-by: Claude Code <noreply@anthropic.com>
SetAgentDirs wrote r.agentDirs with no lock held, while Resolve read the
same field after releasing r.mu.RUnlock(), even though ListAll accessed
it under RLock. Today the write happens at engine construction and does
not overlap with Resolve, but the inconsistent locking meant any future
runtime re-bind raced on the slice header under -race.
- Take r.mu.Lock in SetAgentDirs and copy the incoming slice so later
caller mutations don't leak into the registry.
- Snapshot agentDirs under RLock in Resolve before doing file IO, so
the lock is not held across disk reads but the slice header is read
safely.
- Add a -race regression test that concurrently swaps agent dirs while
other goroutines resolve config and agent commands.
Co-authored-by: Claude <noreply@anthropic.com>
AtomicWriteFile now always writes the initial temp file to the system
temp directory (os.TempDir()), avoiding orphan .tmp-* files in the
target directory when background goroutines (e.g. session manager's
saveLocked) call it concurrently during tests.
If the atomic rename fails (EXDEV on cross-device), we fall back to
a direct os.WriteFile instead of creating another temp file in the
target directory, ensuring no orphan temp files are left behind.