molecule-core/workspace-server
Hongming Wang dcc870a6b7 feat(workspace-server): server-side chat-history endpoint (RFC #2945 PR-C)
Closes the SSOT gap for chat-history hydration: today every consumer
(canvas TS) re-implements an A2A-envelope walk to map activity_logs
rows into rendered ChatMessage objects. This PR moves that walk into
the server.

## What's added

GET /workspaces/:id/chat-history?limit=N&before_ts=T

Returns:

  {
    "messages": [
      {"id": "<uuid>", "role": "user"|"agent"|"system",
       "content": "...", "attachments": [...], "timestamp": "<RFC3339>"}
    ],
    "reached_end": false
  }

Auth chain: same wsAuth as /workspaces/:id/activity (tenant ADMIN_TOKEN
+ X-Molecule-Org-Id). No new trust boundary.

Filter: a2a_receive rows with source_id IS NULL — same canvas-source
filter the canvas applies via /activity?type=a2a_receive&source=canvas,
centralized so future API consumers don't need to know it.

## What's mirrored from canvas TS

Direct port of canvas/src/components/tabs/chat/historyHydration.ts
+ message-parser.ts:

  - extractRequestText / extractFilesFromUserMessage — user-side parts
    walk through request_body.params.message.parts[]
  - extractChatResponseText — agent-side response_body collector across
    the four shapes (string, A2A JSON-RPC parts, older nested
    parts.root.text, task artifacts) joined with "\n" (matches canvas
    multi-source collector — claude-code emits multiple text parts;
    hermes emits summary+artifacts)
  - extractFilesFromResponse / extractFilesFromTask — file walk across
    parts[] + artifacts[].parts[] + status.message.parts[] +
    message.parts[]
  - v0 hot path ({kind:"file", file:{...}}) AND v1 protobuf flat shape
    ({url, filename, mediaType}) both supported
  - Role decision: status='error' OR text starts with "agent error"
    (case-insensitive) → "system", else "agent"
  - isInternalSelfMessage prefix filter (Delegation results are
    ready...)
  - Timestamp pinned to row.created_at (regression cover for
    2026-04-25 bubble-collapse bug)

## Tests

22 unit tests in chat_history_test.go, every TS test case in
historyHydration.test.ts has a Go counterpart:

  Timestamp preservation (3): user/agent pin to created_at, two-rows
  produce two distinct timestamps.

  User-message extraction (5): text-only, internal-self skip,
  null body, attachments hydrated, attachments-only-when-text-empty,
  internal-self suppresses even with attachments.

  Agent-message extraction (4): result-string, status=error→system,
  agent-error-prefix→system, response_body.parts attachments,
  null body, no-text-no-files-no-bubble.

  End-to-end (1): paired user+agent same timestamp.

  Go-specific (5): malformed JSON returns empty (no panic), v1
  protobuf flat shape extraction, task-artifacts extraction, older
  nested root.text shape, basename helper edge cases.

  isInternalSelfMessage predicate (1): prefix match, non-prefix non-
  match, empty-text non-match.

Mutation-tested. Removed the role-promotion branch (status=error +
agent-error prefix → system); confirmed both
TestChatHistory_RoleSystemWhenStatusError and
TestChatHistory_RoleSystemWhenAgentErrorPrefix fire red. Restored.
Both green.

Full handlers test suite (4.3s) green; full repo `go test ./...` green.

## SSOT decision

Parsing logic lives in workspace-server/internal/handlers/chat_history.go
ONLY. Canvas keeps historyHydration.ts + message-parser.ts during the
transition because:

  - PR-C-2 (follow-up): canvas loadMessagesFromDB swaps to new
    endpoint. Today's canvas still calls /activity for backward
    compatibility.
  - The TS parsers are still load-bearing for LIVE message handling
    (WebSocket A2A_RESPONSE events) until RFC #2945 PR-B-2 mirrors
    the typed event payloads to canvas consumers.

Canvas's TS path will be deleted in a separate PR after a one-week
observation window confirms no live-message consumers depend on it.

## Security review

  - Untrusted input? YES — request_body and response_body come from
    agents (potentially OSS / third-party). Defensive: any malformed
    JSON returns empty content + no attachments, no panic. Tested
    via TestChatHistory_MalformedJSONInRequestBodyReturnsEmpty.
  - Trust boundary? Same as today: agent → workspace-server.
    No new boundary; reuses existing wsAuth middleware.
  - Auth/authz? Inherits wsAuth chain. Cross-workspace access blocked
    by existing TenantGuard middleware.
  - PII / secrets in logs? None. The handler logs nothing on the
    happy path; errors log 502 without body content.
  - Output sanitization? ChatMessage.content is plain text returned
    as-is; canvas already sanitizes via ReactMarkdown. Attachment
    URIs are agent-provided (workspace: / platform-pending: /
    https:); canvas's existing scheme allow-list still applies.

## Versioning / backwards compatibility

  - New endpoint /chat-history. /activity unchanged.
  - Canvas historyHydration.ts + message-parser.ts intact during
    transition (will be removed in PR-C-2 follow-up).
  - No public API consumer of /activity is broken — added route is
    additive.
  - No semver bump (server is internal versioning).

## Three weakest spots (hostile-reviewer self-pass)

1. extractRequestText returns ONLY parts[0].text. If a user message
   contains multiple text parts (uncommon — canvas only ever emits
   one), we lose later parts. Matches canvas exactly today, but a
   future change that emits multi-text user messages needs both
   parsers updated. Documented in code; covered by test if/when
   added.

2. activityRowToChatMessages rebuilds ChatMessage IDs every call (no
   caching). Each chat reload mints fresh UUIDs. This is fine because
   canvas dedupes by (role, content, timestamp window) not id, but a
   future API consumer that DID rely on id stability would break.
   Documented in the ChatMessage struct comment.

3. The handler scopes to source_id IS NULL only (canvas-source rows).
   A future "show all messages, including agent-to-agent" mode would
   need a new endpoint or a parameter. Out of scope for PR-C; canvas's
   /activity?source=canvas already enforces the same filter.

Closes #3017. Unblocks RFC #2945 PR-D (MessageStore interface) which
returns []ChatMessage typed values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:17:26 -07:00
..
cmd fix(memory-plugin): embed migrations into binary via go:embed 2026-05-05 11:57:37 -07:00
internal feat(workspace-server): server-side chat-history endpoint (RFC #2945 PR-C) 2026-05-05 23:17:26 -07:00
migrations fix(workspace-server): close TOCTOU race on workspaces(parent_id, name) (#2872 Critical 1) 2026-05-05 21:43:49 -07:00
pkg/provisionhook feat(#1957): wire gh-identity plugin into workspace-server 2026-04-24 15:01:41 +00:00
.ci-force chore: force Platform(Go) CI run on main — validate go vet clean 2026-04-21 15:43:19 +00:00
.gitignore feat(ws-server): pull env from CP on startup 2026-04-19 02:41:15 -07:00
.golangci.yaml chore(workspace-server): add golangci.yaml disabling errcheck 2026-04-24 07:16:54 +00:00
Dockerfile fix(memory-plugin): gate sidecar spawn on cutover-active 2026-05-05 12:39:03 -07:00
Dockerfile.tenant feat(memory-v2): bundle memory-plugin-postgres as in-image sidecar 2026-05-05 11:10:11 -07:00
entrypoint-tenant.sh fix(memory-plugin): gate sidecar spawn on cutover-active 2026-05-05 12:39:03 -07:00
go.mod chore(deps)(deps): bump github.com/creack/pty in /workspace-server 2026-05-02 19:22:48 +00:00
go.sum chore(deps)(deps): bump github.com/creack/pty in /workspace-server 2026-05-02 19:22:48 +00:00