Skip to content

feat(signal): add timestamp edits and opt-in tool progress - #34561

Open
dorukardahan wants to merge 15 commits into
NousResearch:mainfrom
dorukardahan:feat/signal-edit-message
Open

feat(signal): add timestamp edits and opt-in tool progress#34561
dorukardahan wants to merge 15 commits into
NousResearch:mainfrom
dorukardahan:feat/signal-edit-message

Conversation

@dorukardahan

@dorukardahan dorukardahan commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add native Signal text-message edits through signal-cli JSON-RPC send + editTimestamp
  • return Signal text-send timestamps as Hermes message IDs
  • chain the fresh timestamp returned by every successful text edit into the next edit target
  • keep token-by-token response streaming disabled on Signal
  • allow one accumulated text-only tool-progress message only when the user explicitly enables Signal progress (/verbose or display.platforms.signal.tool_progress)
  • keep Signal's built-in tool_progress default at off
  • correct the docs that previously said Signal could not edit sent text messages

Why

Modern Signal and signal-cli support editing already-sent text messages. Hermes already had a generic edit path, but the Signal adapter discarded text-send timestamps and advertised no edit capability. As a result, explicit text edits were unavailable and gateway tool progress was suppressed even when a user opted in.

Signal's edit contract is also different from ordinary message-ID platforms: each edit creates a fresh timestamp, and the next edit must target that timestamp. Reusing only the original editTimestamp can report success while later client updates are silently ignored.

This PR deliberately does not add attachment/media edit semantics. Signal media delivery remains on its existing non-edit path.

Implementation

  • SignalAdapter.send() exposes the signal-cli response timestamp for text messages as SendResult.message_id
  • SignalAdapter.edit_message() edits text via editTimestamp, preserves Signal-native text formatting, supports DM and group routing, and fails closed when signal-cli does not return the fresh next timestamp
  • EDIT_RESULT_ID_IS_NEXT_TARGET makes timestamp chaining explicit instead of inferring it from every adapter's SendResult
  • shared progress, heartbeat, and stream-consumer edit paths adopt replacement IDs only for adapters declaring that contract; Matrix keeps targeting its original event
  • proxy/SSE response streaming honors the same adapter capability gate as local-agent streaming, so Signal's streaming opt-out cannot be bypassed by proxy mode
  • SUPPORTS_STREAMING_EDITS and SUPPORTS_PROGRESS_EDITS separate high-frequency response streaming from lower-frequency, user-enabled text progress edits
  • if an editable adapter sends a progress message without returning an edit handle, Hermes degrades to separate new lines rather than replaying the accumulated transcript in duplicate bubbles

Signal UX

Default behavior is unchanged: Signal remains quiet and sends only the final response.

Opt in with:

display:
  tool_progress_command: true
  platforms:
    signal:
      tool_progress: all

After that, /verbose cycles Signal's progress mode. Text tool progress is accumulated into one edited message; token streaming remains disabled.

Verification

Current-main refreshed head:

199 focused tests passed
56 adjacent streaming/display regression tests passed
255 total passed, 0 failed

Focused coverage includes:

  • text-send timestamp extraction and validation
  • DM/group editTimestamp payloads and native text formatting
  • three-step timestamp chaining
  • progress and heartbeat target adoption
  • Matrix replacement-event regression coverage
  • explicit progress opt-out and Signal default-off behavior
  • proxy-mode enforcement of Signal's token-streaming opt-out
  • missing-timestamp and missing-edit-handle fallback behavior
  • adapter finalize signature compatibility

Additional checks:

  • ruff check on every changed Python file: passed
  • py_compile on every changed Python file: passed
  • git diff --check: passed
  • added-line privacy/credential scan: passed

Advances #39043.

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/signal Signal CLI adapter P2 Medium — degraded but workaround exists labels May 29, 2026
@dorukardahan
dorukardahan marked this pull request as ready for review May 29, 2026 11:04
@vb3

vb3 commented May 29, 2026

Copy link
Copy Markdown
Contributor

Hey @dorukardahan — glad to see this PR. We've been carrying a near-identical patch locally since 5/25 (same editTimestamp semantics, same general shape). Two things from our production soak that might be worth folding in before merge, both observed empirically against a real Signal client running on signal-cli 0.14.4.1:

1. edit_message should return the NEW ts, not echo back the original

The current PR keeps echoing the original timestamp on every edit:

# Keep returning the original timestamp so subsequent edits target
# the same Signal message rather than the edit event's timestamp.
return SendResult(success=True, message_id=str(message_id))

This is correct for the first edit, but causes the 2nd+ edit on the same message to be silently dropped on the receiving Signal client.

Wire-level: signal-cli's send JSON-RPC with editTimestamp chains off the most-recent ts. Each edit's response contains a NEW timestamp that must anchor the next edit:

// First edit response from signal-cli
{"result":{"timestamp": 1779769165998, "results":[{"type":"SUCCESS"}]}}
//                       ^^^^^^^^^^^^^ THIS is the next edit anchor

Referencing the original ts on subsequent edits returns SUCCESS at both the RPC and signal-cli layers, but the receiving Signal client drops the edit silently — no error surfaces anywhere on the sending side. We discovered this empirically when our tool-progress bubble updated once on the first edit, then froze; signal-cli kept reporting every subsequent edit as type: "SUCCESS".

One-line fix on edit_message:

new_ts = result.get("timestamp") if isinstance(result, dict) else None
return SendResult(success=True, message_id=str(new_ts) if new_ts else str(message_id))

2. gateway/run.py::_edit_progress_message needs to propagate the new id back to progress_msg_id

Even with #1 fixed, the streaming use case this PR enables won't work past edit #1 because the drain-loop closure at run.py:16422 doesn't propagate the new id back to progress_msg_id:

async def _edit_progress_message(message_id: str, content: str):
    # currently:
    return await adapter.edit_message(**kwargs)

    # what chained edits need:
    nonlocal progress_msg_id
    result = await adapter.edit_message(**kwargs)
    if result.success and result.message_id:
        progress_msg_id = result.message_id
    return result

One-place change; the 0.15 closure refactor means all 4 callsites inherit it via nonlocal. Platforms whose edit_message returns no fresh id (Telegram, DingTalk) keep progress_msg_id unchanged via the result.message_id guard, so this is platform-neutral.

Why the existing tests don't catch this

The new _rpc mocks return {"timestamp": ...} and the assertions check first-edit success only — they don't simulate the client-side drop of a stale-anchor edit. A test that would catch it under mock:

async def test_edit_message_returns_new_timestamp_for_chaining(self, monkeypatch):
    adapter = self._adapter(monkeypatch)
    async def mock_rpc(method, params, rpc_id=None, **kwargs):
        return {"timestamp": 9999999999}  # NEW server-side ts
    adapter._rpc = mock_rpc
    result = await adapter.edit_message(
        chat_id="recipient-service-id",
        message_id="1111111111",
        content="updated",
    )
    assert result.message_id == "9999999999"  # NEW ts, not the original

Happy to send the two changes as a follow-up PR after this lands, or as additional commits on feat/signal-edit-message if you'd prefer it all in one place. Without the ts-return fix though, the streaming feature this enables will silently break at edit #2.

Drafted with AI assistance; reviewed and verified end-to-end on a production Signal deployment by Vinay Bikkina (@vb3).

@poisdahl

Copy link
Copy Markdown
Contributor

This is useful work and it lines up with part of #39043. One caution before merging this with SUPPORTS_MESSAGE_EDITING=True:

Today that flag is used as more than “explicit edits are possible”. It also affects streaming/progress behavior in the gateway. In current gateway/run.py, one path constructs a stream consumer and changes cursor handling based on adapter edit support, while another path skips stream-edit behavior for adapters without edit support. In current SignalAdapter.send(), returning message_id=None is intentional; the comment says it keeps Signal on the non-edit fallback path instead of pretending future edits can remove an in-progress cursor.

So I think the safer shape is to decouple two capabilities:

  1. stable outbound Signal message ids / timestamps, used for quote/reply, explicit edit, delete, TTL cleanup, etc.
  2. eligibility for high-frequency streaming/progress edits.

The timestamp plumbing itself looks valuable, but flipping Signal directly to streaming-edit eligible may introduce UX/rate/chaining issues. @vb3's note above about chained edit timestamps is one concrete example of why this probably deserves a staged path: first expose stable ids and explicit edit support, then enable streaming/progress edits only after the gateway can track the evolving edit timestamp safely.

AI-assisted note: this comment was drafted with AI assistance and reviewed before posting.

@dorukardahan

Copy link
Copy Markdown
Contributor Author

Follow-up on the streaming/editing concern:

  • Latest branch separates explicit Signal edits from high-frequency streaming edits.
  • SignalAdapter.SUPPORTS_MESSAGE_EDITING = True for direct edit_message() calls.
  • SignalAdapter.SUPPORTS_STREAMING_EDITS = False, and the gateway/stream consumer uses that narrower capability when deciding streaming/progress edit behavior.
  • Regression coverage now checks both explicit edit support and streaming opt-out.
  • Current head a2ef10273453 has all required GitHub checks passing.

@alt-glitch alt-glitch added the sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages label Jul 6, 2026
@vb3

vb3 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Thanks @dorukardahan — the fresh-timestamp return on edit_message is the key fix, and splitting explicit edits from streaming (per @poisdahl) is the right call. One gap remains, and I think it's worth separating a correctness bug from the UX call.

I see three edit cadences here, not two: explicit one-off edits, the tool-progress bubble (batched, throttled to ~1.5s), and token-by-token response streaming (many edits/sec). SUPPORTS_STREAMING_EDITS=False only gates the last one. The tool-progress loop (send_progress_messages / _edit_progress_message in run.py) isn't wired to it — its only capability check is whether the adapter overrides edit_message. Since Signal now does, tool-progress editing runs on Signal whenever tool_progress is enabled, so the "keep tool-progress edits disabled" comment on the adapter isn't actually enforced.

And on that path Signal breaks after the first edit. Signal edits are timestamp-addressed: each successful edit mints a new timestamp the next edit has to target. But the loop pins progress_msg_id to the original id and never adopts the returned one, so edit #2 re-targets the original ts — which signal-cli and the RPC both report as SUCCESS while the receiving client silently drops it. Result: the bubble updates once, then freezes.

The fix should be Signal-scoped, not a blanket "adopt the returned id." Slack echoes the same ts, Telegram/Discord keep the same id on the normal in-place edit, and Matrix returns a fresh replacement event but its later edits must still target the original — so pin-to-original is already correct for all of them; only Signal needs the returned ts to become the next target. So either a small capability ("returned edit id is the next edit target," set by Signal only), or have SignalAdapter track original→latest ts internally so the gateway's stable-handle contract keeps holding for everyone. Worth landing on its own, regardless of the streaming decision.

Separately: whether Signal should show a live tool-progress bubble at all is a fair UX call — but it's distinct from token streaming (a throttled bubble is much milder than per-token edits), so I'd gate it on tool-progress's own capability rather than reuse SUPPORTS_STREAMING_EDITS. If the answer's no, gating send_progress_messages on that helper does it — though the current skip branch drops the queued messages rather than buffering a final summary.

As-is it's the in-between state: tool-progress runs on Signal but breaks at edit #2. Happy to send a follow-up commit either way.

Drafted with AI assistance, reviewed by @vb3.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for separating explicit Signal edits from automatic streaming and progress cadence. The Signal timestamp plumbing addresses a real current-main gap, but two delivery-contract issues need resolution.

Problems

  • gateway/platforms/signal.py:1158-1160 returns the fresh Signal timestamp, but the long-running heartbeat loop reuses _heartbeat_msg_id on every edit (gateway/run.py:19417-19436) and only updates it after a fallback send. A second Signal heartbeat therefore retains the old edit target.
  • gateway/stream_consumer.py:1669 adopts every changed edit result id globally. Matrix returns a new replacement event id (plugins/platforms/matrix/adapter.py:1741-1746) while using the supplied id as the m.replace target (plugins/platforms/matrix/adapter.py:1735-1738); the base adapter contract does not establish that all fresh result ids are valid next-edit targets.

Suggested changes

  • Scope next-target propagation to an explicit Signal capability or keep the mapping in SignalAdapter; cover two successive Signal edits, including the heartbeat caller.
  • Remove or capability-gate the generic stream-consumer id replacement and add a replacement-event regression.

Automated hermes-sweeper review.

Comment thread gateway/platforms/signal.py Outdated
Comment thread gateway/stream_consumer.py Outdated
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 13, 2026
@dorukardahan

Copy link
Copy Markdown
Contributor Author

CI note for head 82bca1b6b: the only failing slice is tests/hermes_cli/test_model_validation.py (two Gemini probe tests). The PR does not touch that code, and both failures reproduce unchanged on clean origin/main@226e8de82. Signal scope remains green locally: 304 tests across Signal/stream consumer/progress topics plus 57 Signal-format tests, along with py_compile, Ruff, and diff-check.

@dorukardahan dorukardahan changed the title feat(signal): support timestamp-based message edits feat(signal): add timestamp edits and opt-in tool progress Jul 19, 2026
@alt-glitch alt-glitch added P3 Low — cosmetic, nice to have and removed P2 Medium — degraded but workaround exists labels Jul 19, 2026
@dorukardahan

Copy link
Copy Markdown
Contributor Author

Refresh complete on head 3971cb365.

  • merged current upstream main into the existing branch without a force-push, preserving the PR history
  • kept Signal token streaming disabled and the built-in Signal tool-progress default at off
  • added explicit timestamp edits plus opt-in, single-message tool progress
  • propagated each fresh Signal timestamp through progress, heartbeat, and stream-consumer edit paths behind EDIT_RESULT_ID_IS_NEXT_TARGET
  • retained Matrix's original replacement target semantics
  • added fail-closed/fallback coverage for missing timestamps and edit handles
  • updated Signal/configuration docs to reflect actual Signal + signal-cli edit support

Verification for this head:

  • focused Signal/progress/config suite: 429 passed
  • all changed Python files: Ruff + py_compile passed
  • Docusaurus production build passed
  • GitHub: All required checks pass; all 8 Python slices, docs, lint/ty, supply-chain, and amd64/arm64 Docker builds are green

The two existing review threads remain resolved. GitHub currently reports the PR CLEAN and MERGEABLE. Main advanced again after CI, but those commits do not touch this PR's files and a synthetic merge is clean.

AI-assisted refresh; reviewed and test-verified before push.

@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Jul 19, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #39043: this implements the Signal edit/progress slice with timestamp chaining and a separate streaming gate; the open issue remains the broader capability spec for maintainer review.

@dorukardahan

Copy link
Copy Markdown
Contributor Author

Follow-up refresh on head ecf19f4cf (supersedes the readiness receipt for 3971cb365).

A final read-only review found that proxy/SSE mode could still construct a GatewayStreamConsumer when an adapter set SUPPORTS_STREAMING_EDITS=False; clearing the cursor did not actually disable token-preview sends/edits. The proxy path now applies the same capability gate as the local-agent path.

Regression coverage drives the real proxy SSE assembly path with a timestamp-returning Signal-like adapter and asserts:

  • the complete response is still assembled for normal final delivery
  • response_previewed remains false
  • no preview send() or edit_message() calls occur

Verification for the current head:

  • focused Signal/progress/config/proxy suite: 453 passed
  • all changed Python files: Ruff + py_compile passed
  • added-line privacy scan: clean
  • GitHub: All required checks pass; all 8 Python slices, docs, lint/ty, supply-chain, and amd64/arm64 Docker builds are green
  • no unresolved review threads or actionable current-head review comments

The branch included current upstream main at push time. Main advanced again during CI without touching PR files; a synthetic merge remains clean, and GitHub REST reports mergeable: true, mergeable_state: clean.

AI-assisted refresh; reviewed and test-verified before push.

Copy link
Copy Markdown
Contributor

One scope clarification from a fresh current-head + signal-cli v0.14.6 audit:

At ecf19f4cf, timestamp message IDs are returned by text send() and the edit path, but not by attachment/media sends. send_image() and the shared _send_attachment() path still track the signal-cli result timestamp internally and then return SendResult(success=True) without message_id; document/image-file/voice/video inherit that behavior.

The edit/revision-chain implementation itself still looks correct and all current review threads are resolved. I suggest narrowing the PR summary to “text sends/edits” and either:

  • explicitly defer attachment/media IDs, or
  • add acceptance coverage for those paths.

This matters for later remote-delete, ephemeral TTL, and durable references: those operations still cannot target media messages even after this PR as currently written.

AI-assisted: Codex checked the current PR head and the tagged signal-cli v0.14.6 source; the finding and comment were reviewed before posting.

# Conflicts:
#	tests/gateway/test_run_progress_topics.py
#	tests/gateway/test_stream_consumer.py
@dorukardahan

Copy link
Copy Markdown
Contributor Author

Refreshed onto current main.

  • resolved merge conflicts in tests/gateway/test_run_progress_topics.py and tests/gateway/test_stream_consumer.py by keeping both the PR's Signal timestamp-chain tests and main's new unrelated tests
  • restored a missing @pytest.mark.asyncio decorator that was lost during conflict resolution
  • Signal scope: 415 passed (signal 147, signal_format 57, stream_consumer 140, progress_topics 48, proxy_mode 24)
  • ruff and py_compile: clean

@dorukardahan

Copy link
Copy Markdown
Contributor Author

Refreshed once more onto current main (3be565fbdee3115ab5b9338551768b8e5e655c56) to replace the prior arm64 Docker job's infrastructure-only failure with a fresh run. The failed job timed out while pulling moby/buildkit from Docker Hub; no project code had executed yet, and contributor permissions cannot rerun upstream Actions jobs directly.

Candidate fbc0c462c4937278cc1c74b407de965581f82ee0 validation:

  • 416 focused Signal/stream/progress/proxy tests passed
  • Ruff, py_compile, and git diff --check: clean

…t-message-20260601

# Conflicts:
#	gateway/run.py
#	gateway/stream_consumer.py
#	tests/gateway/test_run_progress_topics.py
#	tests/gateway/test_signal.py
#	tests/gateway/test_signal_format.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/signal Signal CLI adapter sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants