A review pass over domain/schedule found three genuinely untested
branches, all of which now have cases:
- ensure_launchable with a naive `now`. next_after already had this
covered; the delay floor did not, so a caller handing over a naive
clock reading could have had it shifted by the local offset unnoticed.
- update_task changing the prompt. Only the title path was exercised.
- _save finding the row gone. get_task saw it and save no longer does,
which is a concurrent delete; the caller must get the same not-found it
would have got a moment earlier rather than a None leaking out.
That takes service.py and every model module to 100%. The one remaining
uncovered line is croniter's naive-return guard, carried over verbatim
from schedules.py and unreachable with an aware input -- it now says so
instead of looking like an untested branch.
Also fills in the documentation the migration skipped: TaskStatus,
ContextMode and RunStatus arrived from the original draft without
docstrings while their newer siblings had them, and ScheduleService plus
four of its use cases were undocumented. Each now records the reasoning a
reader would otherwise have to reconstruct -- why RUNNING is not "the
agent is executing", why SKIPPED never passes through QUEUED, why
INTERRUPTED is not FAILED.
CRON_FIELD_COUNT stops being exported: it has no consumer outside the
module that defines it.
Four changes, all from the same observation -- the service was expressing
domain concepts in language the domain does not use.
DispatchOutcome replaces the Literal. The caller branches on all four
values and the SKIPPED/CONFLICT distinction is itself a business rule, so
it is domain vocabulary like every other enum in this context; a bare
string was the odd one out.
lease_owner leaves the domain entirely. It was a pure pass-through: the
service held it only to hand it to claim_due, which never reads it back.
Which process claimed a task is an identity, not a rule -- an adapter can
record one for diagnostics without the domain carrying it. lease_seconds
stays, because how long a claim survives genuinely changes recovery
behaviour.
The _UNSET sentinel is gone. It existed for one field: thread_id, the only
update parameter with a meaningful None. But thread_id and context_mode
always move together, so packaging them as ContextChange removes the
ambiguity and lets every other field use plain None for "not supplied" --
which also matches what the HTTP layer already does with exclude_none.
The previous `title: str = _UNSET` annotation was simply untrue.
pause/resume no longer route through a _transition helper taking an
unbound method. Two direct bodies plus a _save that raises on a missing
row read better, and _save documents why this stays a read-modify-write:
pushing "not while running" into a storage predicate would put the rule
beyond a zero-IO test and give it a second home. Closing that window
properly needs optimistic locking, which needs a schema change.
Also cleans three lint findings surfaced by a broader rule set than the
project enables: a regex metacharacter in a pytest match=, an unused
override parameter, and two deliberately-naive datetimes that now say so
with a noqa rather than looking accidental.
The input port of the context: every scheduled-task use case, orchestrated
over the four output ports. It holds no business rules -- each decision is
delegated to the aggregate -- and `test_schedule_service.py` runs the
complete lifecycle with no HTTP, no database and no run runtime, which is
the acceptance criterion this migration was for.
`dispatch_task` mirrors the pre-migration structure line for line,
including its comments, and makes exactly three substitutions: bare dicts
become domain objects, the HTTPException-409 sniffing becomes `except
ThreadBusyError`, and the status-derivation static methods become
aggregate methods. Its four exits and their two conflict paths are
unchanged, and the tests pin the collapse: the fast-path rejection and
the active-slot rejection must produce identical results, asserted over
every field of DispatchResult.
One behavioural narrowing, deliberate. The old code wrapped the launch
*and* its follow-up writes in `except Exception`, so a failing bookkeeping
write was recorded as a failed launch -- marking an execution that had
actually started as failed. The port contract admits exactly two escapes
from `launch`, so only the launch is guarded now and a genuine write fault
propagates instead of being misreported.
SchedulePolicy gains max_concurrent_runs and lease_seconds. Both are
operator-tunable thresholds the domain needs but must not read, which is
what that value object is for; the claiming process's identity stays a
constructor argument since it is an identity, not a threshold.
Four Protocols the domain declares and the outer ring will implement,
plus the two DTOs that keep infrastructure types out of the inner ring:
- ScheduledTaskRepository / ScheduledRunRepository, exchanging domain
objects rather than the bare dicts the current repositories return
- RunLauncher, whose contract is that only ThreadBusyError or
LaunchFailedError may escape -- that translation is what keeps the run
runtime and the web framework out of the domain
- ThreadLookup, one method rather than the whole thread store
- LaunchedRun and RunOutcome, so the completion path stops taking a
runtime record the purity test would reject
Two deliberate departures from the earlier sketch. There is no Clock
port: `now` is already an explicit parameter throughout, so the domain
never reads a clock and the tests are already deterministic -- adding
one would only create a second source of truth for the same value. And
`record_launch` is not expressed as `save(task)`, because
`protect_terminal` makes it a compare-and-set against a concurrently
finalizing run; a read-modify-write through the aggregate would
reintroduce the race the flag exists to close.
The in-memory doubles model the active-slot rule rather than skipping
it: a double that never refuses a second active run would let the
service's conflict collapse go untested. Their semantics are pinned by
test_schedule_fakes.py, which becomes the contract suite once the SQL
adapters land and both tiers run the same cases.
Concurrency is out of scope for the doubles and says so in their module
docstring -- it stays covered against a real database in
test_scheduled_task_dispatch_race.py.
`_finalize_skip` carries a task's launch bookkeeping over unchanged by
reading the current values off the task dict and passing them back into
`update_after_launch`. But repository dicts hold ISO *strings* for
timestamps -- `_row_to_dict` runs every datetime column through
`coerce_iso` -- while `scheduled_tasks.last_run_at` is a DateTime
column, so that round trip fed a string into a datetime bind parameter
and raised StatementError.
It only reproduces once the task has launched at least once: before
that `last_run_at` is NULL, which the column accepts. Every existing
dispatch test seeds a fresh task, so none of them reached it.
The blast radius was the whole poll cycle rather than the one task: the
exception escapes `dispatch_task` into `_run_loop`, so every task still
queued behind it in that round goes undispatched, and the failing task
holds its lease in `running` until it expires. The skip tombstone was
already written by then, so the run history was left inconsistent with
the task row.
Coerced at the call site instead of loosening the repository's parameter
type: the skip path is the only one that round-trips a stored timestamp,
every other write passes `now` straight through.
First step of the scheduled-task context's hexagonal migration: the
inner-ring model, with zero infrastructure dependencies.
- ScheduleSpec / SchedulePolicy value objects
- ScheduledTask aggregate root
- ScheduledRun aggregate
- 9 domain errors, 5 enums
Rules are migrated verbatim from their current homes, each method's
docstring citing the source line: timezone/cron/next-run calculation
from deerflow/scheduler/schedules.py, context-mode and re-arm rules from
routers/scheduled_tasks.py, and the four status-derivation rules from
app/scheduler/service.py.
Two things previously held by convention are now enforced by
construction. Validation and normalization live in __post_init__, so
building a ScheduleSpec field-by-field cannot bypass them. The skipped
tombstone is a separate factory, so it can never be written as the
transient queued row that would collide with uq_scheduled_task_run_active.
The domain does not serialize itself: mapping the stored schedule_spec
JSON in and out stays with the adapter layer, keeping Mapping[str, Any]
out of every domain signature.
Production code still runs through app/scheduler/service.py -- this
commit adds no call sites and changes no behavior.
Four findings from @willem-bd on #4401:
- FeedbackDialog stays mounted across messages, so selected tags and the
comment survived an ESC/click-outside dismiss and pre-filled the next
thumbs-down. Reset on every close path via a wrapped onOpenChange.
- Neither the dialog's handleSubmit nor handleDialogSubmit caught a failed
enrichment PUT, so a rejection went unhandled and the user got no signal.
Catch in the dialog (where the rejection lands), toast, and keep the input
for a retry.
- rate_run awaited the RunLookup port before Feedback.create validated the
rating, contradicting its own "before any I/O happens" docstring: an
invalid rating on an unknown run surfaced as RunNotFoundError. Validate
first, restoring the legacy router's 400-before-404 ordering. The service
test now uses an unknown run id so it actually pins that order.
- InMemoryFeedbackRepository moved out of test_feedback.py into
tests/feedback_fakes.py (with FakeRunLookup) so two test modules share it
without one importing the other; conftest states the tests-dir sys.path
dependency explicitly, which also makes it work under
--import-mode=importlib.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebase the feedback migration onto main's new chain tip: main added
0008_thread_operation_kind (also chained after 0007), so
0008_feedback_tags becomes 0009_feedback_tags with
down_revision=0008_thread_operation_kind, keeping the chain linear.
The five head-pin test conflicts resolve to 0009_feedback_tags on top
of main's versions (preserving the new operation_kind assertions).
Verified: 42 migration/bootstrap tests, 54 feedback tests, and the
full frontend suite (797) pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merging main rebased this branch's feedback migration to
0008_feedback_tags on top of main's 0007_scheduled_run_active_index,
so the chain head moved past the pin in main's new dedupe test. The
test runs `upgrade head` and asserts the chain tip; its actual subject
(the dedupe pass + partial unique index) is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): linearize delta-mode checkpoint resume
Resuming a run from an older checkpoint forks the lineage, and in delta
mode that fork's state cannot be materialized correctly: the delta
history walk collects every pending_writes entry stored on each on-path
ancestor, but a shared parent also carries the writes of the sibling
child that was abandoned. Those writes replay into the fork, so the run
starts from a message list that still contains the answer it was meant to
replace — regenerating in a branched thread surfaced this as the
superseded assistant message reappearing beside the new one after a
reload. All three saver implementations are affected, so write-to-child
ownership is a gap in the upstream delta contract rather than one
saver's slip.
Rather than reimplement that walk, express the fork as what it means:
materialize the requested checkpoint's state, write it as an Overwrite on
the current head (which has no siblings), and run linearly. The abandoned
turn stays in history as the rewritten head's ancestry.
This runs after the rollback point is captured, so cancel-with-rollback
still restores the real pre-run head, and fails closed — an unreadable
resume checkpoint raises instead of falling back to the corrupt fork.
Full mode keeps forking: its checkpoints carry complete channel_values
and need no replay.
* fix(runtime): restore complete delta resume state
* fix(runtime): linearize delta rollback restoration
* Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(runtime): serialize delta resume preparation
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Opening the dev stack on a LAN address or a proxied hostname serves the
SSR HTML but never hydrates: Next.js answers /_next/*, /__nextjs_font/*,
and HMR with 403 for any host it was not started on. The page renders, so
it looks up — but no client handler is attached, and the login form's
onSubmit never fires. It reads as "login is broken" rather than as an
asset problem, and the only clue is a warning in the dev-server log.
Wire Next's allowedDevOrigins to a new DEER_FLOW_DEV_ALLOWED_ORIGINS env
var. Unset by default, so the localhost-only default is unchanged; it is
also dev-only, as Next ignores allowedDevOrigins in production builds.
Entries are reduced to the bare host that allowedDevOrigins matches
against, since an entry pasted from the address bar as
"http://192.168.1.10:2026" would otherwise match nothing and leave the
operator with the same 403 they were trying to fix.
Reported in #54 and #203.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): restore resizing for the artifacts and sidecar panels
#3934 replaced the right panel's ResizablePanelGroup with a fixed CSS grid to
animate open/close, which removed the drag handle; #4187 then reintroduced a
resizable group for the browser panel only. The artifacts and sidecar panels
have had no way to resize since, while the browser divider still drags.
All three right panels now share one panel group, so there is no per-panel-kind
layout fork. Open/close goes through the side panel's collapse()/resize() so the
width still animates, and a dragged width survives closing and reopening.
Three library-specific constraints, each found by a failing test:
- the size transition is applied from the group as
[&>[data-panel]]:transition-[flex-grow], because <ResizablePanel className>
lands on an inner wrapper while the element the library sizes is its own
[data-panel] div;
- reopening uses resize(remembered) rather than expand(), which falls back to
minSize until the library has recorded a size, and the remembered width is
read before collapse() because the closing animation reports shrinking sizes;
- during the animation the content is held at its final width in cqw and
clipped, as the previous grid layout did — letting it reflow every frame makes
the message list re-run its scroll-to-bottom and re-wraps the sidecar
composer.
Fixes#4465
* fix(frontend): remove unreachable panel max size
* feat(memory): signals-based update pipeline + always-on watermark/trivial filter
Refactor the DeerMem memory update pipeline (message_processing -> queue ->
updater) around a signals frozenset seam, replacing the
(filtered, correction_detected, reinforcement_detected) 3-tuple with
(filtered, signals: frozenset[str]) end to end.
message_processing:
- Externalize signal-detection patterns to YAML (message_patterns/*.yaml).
- Extend signals from correction/reinforcement to a 6-class set
(correction/reinforcement/preference/identity/goal/decision); detect_signals
returns a frozenset aligned with the fact category enum.
- Pure-acknowledgment turns ("ok"/"好的"/...) are always filtered out before
enqueue (whole-message fullmatch), saving an extraction LLM call.
queue (core/queue.py):
- In-memory list + debounce timer, with flush_sync (graceful-shutdown drain
that joins an in-flight worker under a hard timeout) and queue_max_depth
backpressure (signal-bearing updates always admitted; QueueFull otherwise).
- Same-key updates coalesce with a signal union; per-batch success/fail summary.
updater (core/updater.py):
- head500+tail500 message truncation (replaces the 1000-char head chop).
- Always-on per-thread watermark: feed only messages added since the last
extraction. The watermark is in-memory and is not advanced on failure, so a
failed/lost update is re-fed on the next conversation turn.
- [MANUAL] prompt marker for user-authored facts (source.type="manual").
- Post-invoke extraction_callback (host-injected) emitting facts_extracted /
facts_accepted / rejected_low_confidence; the host default logs metrics and
flags >60% rejection.
Confidence filtering remains in _apply_updates (the existing
fact_confidence_threshold check); there is no separate write gate.
Consolidation stays opt-in (lossy). The ABC add/add_nowait signature is
unchanged, so the summarization flush hook and host are unaffected.
Tests: add test_message_processing_signals, test_updater_truncation,
test_updater_watermark; update queue/updater/consolidation/staleness/pluggable
tests for the signals seam.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(memory): harden update pipeline per PR review
- Catch QueueFull in DeerMem.add/add_nowait so backpressure degrades to
'update skipped' instead of propagating into after_agent /
summarization_hook and breaking the agent run (peer middlewares
self-guard; MemoryMiddleware was the lone exception). Emergency
(add_nowait) always admits under backpressure -- its data cannot be
re-fed next turn.
- Rewrite the watermark from index-based to content/identity-based
(_message_identity + _feed_after_watermark) so it stays correct when
summarization removes the conversation front -- an index watermark
pointed at the wrong message and silently skipped un-extracted tail
turns. The emergency flush bypasses the watermark (bypass_watermark on
ConversationContext, threaded through update_memory) and coexists with
(does not replace) a pending normal update, so a flush cannot drop a
pending update's un-extracted tail.
- Populate facts_accepted / rejected_low_confidence inside _apply_updates
at the real confidence-filter site (passed_threshold) instead of
re-deriving the threshold in _finalize_update -- eliminates metric drift.
- Emit extraction metrics in a finally with an 'attempted' flag so
exception failures (parse error, apply_changes raise after retry) are
observable, not only the happy path.
- Re-detect signals on the post-watermark feed for the extraction hint so
it no longer references turns the LLM cannot see; admission-time signals
still drive backpressure.
- Move the post-batch reschedule inside the queue lock to close a
non-atomic self._timer race with a concurrent add.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(memory): address follow-up review nits (LRU, metric name, docstring)
- Bound the in-memory watermark cache with a configurable LRU
(watermark_max_keys, default 4096, 0=unbounded). A dropped key re-extracts
one batch on that thread's next turn (the documented restart behavior), so
eviction is safe and preserves the content-identity watermark's
front-removal guarantee. Adds _watermark_get/_watermark_set helpers and a
bounded-LRU regression test.
- Rename the extraction metric facts_accepted -> facts_passed_confidence so
the name matches what the >60% rejection-rate warning assumes (a
confidence-gate signal, not a persisted-fact count); drop the stale
"historical semantics" justification. Brand-new callback, one consumer.
- Fix the stale test_message_processing_signals module docstring: the signals
seam is already swapped to frozenset, and a stale stage-numbering prefix is
removed.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(frontend): pin recent chats
* fix(threads): address pin-chat review feedback
- Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new
update_metadata(touch=False) path so unpinning no longer jumps a chat
to the top of the updated_at-sorted recent list.
- Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching
the Gateway's actual response (no values/context).
- Namespace the pinned metadata key as deerflow_pinned for consistency
with deerflow_sidecar / deerflow_branch.
- Cover touch/touch=False behavior in repo + router tests; document the
e2e mock's updated_at preservation now mirrors production.
* style(frontend): format thread utils test
* fix(threads): make pinned ordering server-side
* test(frontend): keep infinite-scroll fixture order stable
* test(frontend): stabilize lark reconnect e2e
* docs: clarify thread pin metadata contract
RunCreateRequest declared stream_resumable as None-only, but langgraph_sdk
defaults it to False (not None) and its payload filter only drops None, so
every SDK request carried "stream_resumable": false and was rejected with 422.
False means "no resumable stream", which is what DeerFlow already serves.
This broke every IM channel run (runs.stream and runs.create both send the
field; only runs.wait omits it) and any external langgraph_sdk client. The web
frontend was unaffected because its compatibility wrapper does not send it.
An explicit true still returns 422. The new regression test drives a real SDK
client to capture its default payload and posts that to the HTTP boundary, so a
future non-None SDK default cannot regress this silently.
Fixes#4466
* fix: surface length-capped model responses
* fix: avoid the influence of the mid-turn
* fix: correcting semantic annotations
* fix: add ModelLengthTerminationDetector to compatible providers
* fix:delete redundancy code
* fix:supplementing log information improves observability
* fix: align the document and complete the assertions.
* fix: unit test
* fix: revert AGENTS.md
* fix: unit test
* fix: add annotation and skip AIMessage has empty content
* fix(gateway): stop persisting Overwrite wrappers into empty reducer channels on branch
Thread branching (and POST /state on a never-written channel) wraps copied
reducer values in Overwrite. Upstream BinaryOperatorAggregate.update seeds
an empty (MISSING) channel with values[0] verbatim without unwrapping, so
Union-typed channels (sandbox/goal/todos/promoted) stored the wrapper
literally and the next consumer crashed with TypeError: 'Overwrite' object
is not subscriptable (#4380). Patch the channel to unwrap the first write
(mirroring DeltaChannel semantics), and stop copying thread-scoped channels
(sandbox, thread_data) into branches: the parent's sandbox_id would bind
the branch to the parent's workspace and release lifecycle.
* refactor(checkpoint): drop private _get_overwrite import for a local Overwrite check
Importing langgraph's underscored _get_overwrite at module top level meant an
upstream refactor that drops it - plausibly the same release that fixes the
bug - would fail this module's import and crash startup before the probe can
stand the patch down. Replace it with a local helper on the public Overwrite
type, and fix two test docstring nits.
* refactor(checkpoint): write patch flags via their constants to avoid drift
Both saver patches read their "already patched" idempotence flag through a
module constant (_PATCH_FLAG / _BINOP_PATCH_FLAG) but wrote it as a hard-coded
attribute literal, so renaming the constant would silently break the guard and
double-apply the patch. Write via the same constant (setattr), dropping the
now-unneeded attr-defined ignores.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): recover from setup status timeouts
* test(auth): cover setup status recovery flows
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): unwrap Overwrite-wrapped sandbox state in after_agent
Fork-restored checkpoints can deliver the sandbox channel still wrapped
in langgraph.types.Overwrite: the rollback restore applies replace-style
writes through a state-mutation graph in delta checkpoint mode, and
after_agent/aafter_agent then crash subscripting the wrapper
("TypeError: 'Overwrite' object is not subscriptable") on the next
sandbox tool run in the forked conversation. Unwrap before reading the
sandbox id, and pin both hooks against an Overwrite-wrapped state.
Refs #4380 (bug 1 of 2; the history-loss half is a separate display path)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* fix(sandbox): don't release fork-restored parent sandboxes
The Overwrite-wrapped value replays the parent thread's sandbox state, so releasing it from the forked run would evict the parent's warm sandbox. _unwrap_sandbox now reports the wrapped form, and both after_agent hooks skip the release for it while keeping the normal path unchanged.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
---------
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* perf(frontend): coalesce streaming renders to a frame budget instead of per chunk
While a run streams, the merge/group/render pipeline consumed every SSE chunk
as its own React update (~60/s), re-rendering the whole thread tree per token.
Enable the SDK's same-tick batching (throttle: true) and publish the
render-facing messages snapshot at most once per 80 ms with a leading edge and
a trailing flush, keyed through a memoized merge so identities stay stable
between flushes. Lifecycle consumers (optimistic clearing, summarization
capture, usage baselines) keep reading the per-chunk array.
* perf(frontend): keep the transient bridge order array identity stable
mergeTransientHistoryBridgeOrder cloned unconditionally, so the render-time
call handed the coalesced merge memo a fresh array identity on every render
while the transient history bridge was open, re-running mergeMessages between
flushes. Clone lazily and return the input order when nothing is appended; the
merge only ever appends, so an unchanged length means unchanged content.
Consumers only read the returned order, so reusing the input is safe.
* perf(frontend): drive the render coalescer from a monotonic clock
The coalescing interval was measured with Date.now(). A backward wall-clock
step (NTP correction, sleep/wake) turns the elapsed term negative, so the
scheduled delay becomes interval + jump and the rendered snapshot stalls for
the length of the jump. Read performance.now() once per effect invocation
instead; the timer callback re-reads it because timers fire late and the next
interval must start from the real flush.
Seed the last-flush marker with -Infinity so the first update of a stream
still takes the leading edge under a page-load-relative clock.
* perf(frontend): reset the coalescer flush baseline when a stream ends
The leading-edge flush was scoped to the hook instance rather than to each
stream: a run starting within one interval of the previous one found a recent
flush baseline and deferred its first frame. Drop the baseline when leaving
the streaming state so every stream opens on the leading edge.
* perf(frontend): disarm the trailing flush when the leading edge wins
decideCoalesce checks the elapsed interval before the pending-timer flag, so
an update arriving past the interval takes the leading edge while a trailing
timer is still armed. Timers fire late under main-thread load -- exactly the
regime this coalescer targets -- so that timer then publishes a second time
and slips the flush baseline forward, breaking the at-most-one-flush-per-
interval property when it matters most.
Disarm the pending timer in the flush-now branch, and cover the previously
untested elapsed >= interval && hasPendingTimer quadrant.
* perf(frontend): stop syncing the render snapshot while idle
The snapshot is only read while streaming, so keeping it current on every
idle messages change costs one wasted render per history refetch or thread
navigation. Dropping that publish outright is not safe either: the leading
edge runs in a passive effect, but the render where isStreaming flips true
paints first and returns the snapshot, so a stale one would be painted --
after a thread switch, another thread's messages, since the chat page
deliberately avoids re-mounting on navigation.
Make the snapshot nullable, where null means no snapshot belongs to the
current stream, and return the live array while it is null. The idle branch
then writes state once per stream end instead of once per idle update, and
the stale-frame window does not exist rather than being short.
* feat: add lark cli integration
* fix: polish lark integration actions
* feat: support lark incremental permissions
* fix: detect lark authorization completion
* fix: harden lark integration install
* feat: expand lark auth scopes and reuse host auth in sandbox
Default lark auth to least-privilege (recommend=false, base sign-in only)
and expose the full set of lark-cli --domain business domains as native
--domain grants instead of a 4-domain read-only mapping. Resolve the
skill pack from the latest larksuite/cli GitHub release at install time
with content-hash integrity, and surface version/runtime drift in status.
Share the per-user lark-cli config/data profile between the Gateway
Settings auth flow and agent conversations by mounting the integration
dirs into the AIO sandbox and injecting the matching env for lark-cli
commands, with an allowlisted extra_mounts path in the provisioner/K8s
backend and traversal guards on integration paths.
* style: fix lint issues from ruff and prettier
Sort imports in the provisioner PVC test and re-wrap two long i18n
description strings to satisfy backend ruff and frontend prettier CI.
* fix(lark): address managed integration review feedback
* fix(frontend): stabilize integrations settings e2e
* test(sandbox): isolate remote backend legacy visibility check
* test: fix backend unit failures after merge
* Harden Lark integration review fixes
* Format Lark integration E2E test
* fix(lark): harden sandbox credential exposure and status disclosure
Address willem_bd's security review on PR #3971:
- Mount the per-user lark-cli config dir (long-lived appSecret) read-only
into the AIO sandbox; only the refreshable-token data dir stays writable.
- Redact host filesystem paths (install_path, cli.path) from
GET /lark/status and the config/auth complete responses for non-admin
callers, fail-closed on any auth error.
- Document the npm postinstall trade-off (--ignore-scripts is not viable
because @larksuite/cli fetches its platform binary in postinstall).
- Document the sandbox credential trust boundary in AGENTS.md and README,
pointing at the sidecar-broker follow-up (#4338).
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Branch creation seeds the new thread's run-event feed from its checkpoint
so inherited history survives the first run (#4380). Every seeded row
carried one shared run id, but run_id is a *turn* identity to the feed's
consumers, not a provenance tag: regenerating the inherited answer
resolves that row's run id as the superseded source, and
GET /messages/page then drops every row carrying it. One shared id for
the whole seed therefore deleted the complete inherited history on a
branch's first regenerate, leaving only the regenerated turn.
Group seeded rows into one synthetic run per inherited turn
(branch-seed-{thread_id}-{n}), a new turn opening at each persisted human
message — the same boundary a real run has, including the allowlisted
hidden ask_clarification reply, which resumes as its own run. Supersession
is then confined to the turn actually regenerated.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Frontend unit tests run in a plain node environment, so nothing that
renders can be covered and no hook can be tested under real React. The
workaround that forces is already in the tree: the use-global-shortcuts
test mocks out react itself, so its useEffect never re-runs on a
dependency change and never goes through React's commit/cleanup
ordering -- it pins the stub rather than the hook.
Split the suite into two rstest projects: *.test.ts(x) keeps running on
node, *.dom.test.ts(x) runs on happy-dom. A global DOM environment was
measured first and rejected -- it takes the same 743 tests from 3.3s to
9.5s -- and rstest has no per-file environment docblock, so projects is
the only way to charge that cost to the tests that need it.
useIsMobile is the first consumer: it had no tests and cannot have any
without a document, since it reads window.matchMedia.
* fix(summarization): own the run model for compaction; bound failure
With summarization.model_name: null the summary model resolved to
config.models[0] while the executing model is selected per run; when they
differ and models[0]'s provider is broken (expired key, quota, outage)
compaction silently failed every triggered turn and context grew unbounded
until the main provider 400s the run (#3103's shape), even though the run's
own model was healthy.
Model ownership is now sourced from the builders, not re-derived at runtime:
- The lead, subagent, and manual /compact builders each pass the resolved run
model into create_summarization_middleware(run_model_name=...). The middleware
no longer reads runtime.context / get_config(), which do not carry a custom
agent's or a subagent's resolved model, so a custom-agent lead run and a
distinct-model subagent now summarize with their own model, not models[0] /
the parent's. Runtime re-resolution and the per-name model cache are removed.
- model_name: null summarizes with the run's own model; an explicitly configured
summary model generates and falls back to the run model on failure. The
fallback is built lazily after the primary fails and its construction is
guarded, so a broken fallback cannot skip a healthy primary or escape the
automatic failure boundary.
Failure is bounded and side-effect-safe:
- An empty or whitespace-only response is treated as a generation failure, not a
valid summary, so compaction never removes all history for an empty replacement.
- compact_state/acompact_state take raise_on_failure independent of force: the
manual /compact path always surfaces a generation failure (even force=false)
and routes it to the existing ContextCompactionFailed path (HTTP 500 ->
frontend error toast) instead of an unconsumed response reason. The automatic
path leaves compaction state unchanged.
- before_summarization hooks fire only after a replacement summary exists.
SummarizationConfig.model_name, config.example.yaml, and docs/summarization.md
document the final lead/subagent/manual ownership rules.
Part of RFC #4346 (section A). Evaluating fraction/triggers against the run
model's profile (profile ownership) is a separate follow-up.
* fix(summarization): manual /compact model ownership + fail-open construct/parse
Manual /compact carried only agent_name, so it derived the run model from the
custom-agent model or config.models[0] and missed the request-selected model the
run path uses (request -> custom-agent -> default). Carry model_name through
ThreadCompactRequest and the frontend compact call, resolve with the same
precedence, and move the custom-agent config read off the event loop (asyncio
.to_thread) with user_id so the strict blocking-IO gate is not bypassed by the
broad except.
Make one summary attempt own its full lifecycle so the fail-open boundary covers
construction and response parsing, not just invocation: build each candidate model
lazily and guarded (a raising constructor falls through to the healthy run model
instead of breaking agent construction), build the model_name:null primary from the
run model rather than config.models[0], and run response text extraction inside the
invocation try so a failing .text accessor falls back instead of escaping compaction.
Adds factory-level constructor-failure, response-extraction-failure (sync/async), and
route-path model-ownership tests.
Renumber the feedback tags migration to 0008 on top of main's
0007_scheduled_run_active_index so the alembic chain keeps a single
head; bootstrap head pins follow.
Queue comments received while a run is active, then submit one deduplicated follow-up after it finishes. Failed drains are requeued and watcher tasks stop cleanly with the channel manager.