Files
multica/server/pkg/db/generated/task_message.sql.go
T
e04465be0b feat(transcript): tell the reader when a tool output was truncated MUL-7168 (#8212)
* MUL-7166 MUL-7167 feat(transcript): tell the reader when a tool output is incomplete

A tool_result preview is cut at 8 KiB and the remainder is never uploaded.
Nothing recorded that, so the run transcript rendered a truncated preview
exactly like a complete one — a reader checking what an agent did could take
the first 8 KiB of a 300 KB log for the whole thing.

The daemon now reports whether the preview dropped bytes, and the viewer says
so. Two separate claims, kept separate:

- Per step: a "truncated" badge next to the tool name when the record
  provably lost bytes.
- Per run, once: a caveat when the run contains output whose completeness
  nobody measured.

The flag is tri-state end to end (NULL/absent = unknown, false = complete,
true = truncated) and `output_truncated` is nullable with no default and no
backfill. Historical rows cannot be reconstructed, so they stay unknown; the
same applies to messages from an older installed daemon, which is why the
batch insert carries the value through the query's text[]/NULLIF encoding
rather than a []bool that cannot express the third state.

Display clipping is now worded as what it is. The inspector's 8000-character
clip said "... (truncated)", the same word the badge uses for bytes that are
gone, when copy still yields the whole stored text.

Verified: go test ./internal/handler ./internal/daemon ./internal/util
./internal/migrations, pnpm test (4981 views + core), typecheck, lint. Both
states captured in a local environment against real API + DB rows.

Co-authored-by: multica-agent <[email protected]>

* refactor(transcript): move the completeness note to the end of the output

The truncation indicator was a warning badge beside the tool name and a
run-level banner above the step list. Both announced the caveat before the
reader had a reason to care, and the badge's colour gave a routine 8 KiB cut
the weight of a failure.

"Was that all of it?" is a question you only have once you reach the bottom of
an output, so the answer goes there: one muted line at the end of the body,
in the same place the display-clip line already sits.

The unknown case follows the same rule rather than keeping its own banner —
the inspector shows one step at a time, so a note there appears once per
opened step, not once per turn in a list. That was the whole objection to the
per-turn disclaimer this design replaced; a run-level bar was one way to avoid
it, and putting the note where the evidence is another.

`outputCompleteness` replaces `hasUnknownOutputCompleteness`: the same rules
(tool results only, empty output excluded, false is a measurement) now answer
per item instead of per run.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): stop the display-clip line promising the whole output

"copy to get the whole record" reads as "the full output is in the database".
It is not: the daemon cuts at 8 KiB before reporting, and copy returns the
stored record — the same bytes, minus the 8000-character render clip. The
first reader of this string took it the other way, which is the whole reason
the truncation note exists.

Says what copy actually returns: everything that was saved.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): leave a stored tool result with one remark, not two

The render clip cut at 8000 characters, but a stored tool result is already
capped at 8192 bytes upstream — so on an ASCII output the clip could only ever
shave a further ~190 characters, and it did so under a note that already said
the rest was not saved. Two grey lines, the second reporting a rounding error
on the first.

The clip now sits above the source budget, where a tool result cannot reach it.
Tool input keeps it: input is persisted whole and has no server-side budget, so
it is the body that actually needs bounding — and it never carries a
completeness note, so that path stays a single remark too.

The marker also stops mentioning copy. It described the clip's reversibility,
which is only interesting next to a truncation note that is no longer beside it.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): say why the output is short, not where it stops

"Output ends here" reads two ways: the command produced this much, or we cut
it. Those are opposite facts about the agent's run, and the note exists to
settle exactly that question.

States the cause instead: the output was too long, so only the beginning was
kept. "Kept" rather than "shown" — the rest was never uploaded, and wording it
as a display limit invites the assumption that the whole output is in the
database and merely hidden.

The unknown note loses the same phrase for the same reason.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): address review on the completeness note

Three fixes from review of #8212, plus the frequency question it reopened.

A malformed `output_truncated` deleted the whole run from the screen. The
field parsed with `z.boolean().optional()`, so a string or a number failed its
row, the row failed the array, and `parseWithFallback` handed the viewer an
empty transcript. It now catches at the field, degrading to unknown. The old
test claimed this but passed a malformed top-level object, which exercised a
different path; the replacements fail without the catch.

The note appeared before the reader could have wanted it. `ToolDetailSurface`
collapses its own body, so a footer rendered outside it sat over a faded,
half-shown output — answering "was that all of it?" to someone still reading.
The surface now owns the note and withholds it until the body is revealed.

The render clip went back to 8000 characters for tool input. Raising it to
20000 was collateral: the goal was to stop re-clipping a tool result, which is
already capped at 8192 bytes upstream, and skipping the clip for `tool_result`
alone does that without touching how an unbounded input renders.

Unknown-ness moves back to the run. One daemon produces every message in a run,
so either it measured that run's outputs or it measured none of them — a fact
about the run, stated once, rather than a remark repeated on each step opened.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): drop the notice for records that predate the flag

A record from before the flag existed now says nothing. The viewer speaks only
when a daemon measured an output and found bytes missing.

The column stays tri-state. Nothing renders differently for NULL and false
today, but they are not the same fact, and defaulting NULL to false would write
"this daemon measured the output and stored all of it" onto rows where nobody
measured anything. The original lengths are gone, so that claim could never be
corrected — a lie the schema would keep telling long after this UI changed
again.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 14:40:45 +08:00

290 lines
9.2 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: task_message.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createTaskMessage = `-- name: CreateTaskMessage :one
INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, output_truncated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, task_id, seq, type, tool, content, input, output, created_at, output_truncated
`
type CreateTaskMessageParams struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
Type string `json:"type"`
Tool pgtype.Text `json:"tool"`
Content pgtype.Text `json:"content"`
Input []byte `json:"input"`
Output pgtype.Text `json:"output"`
OutputTruncated pgtype.Bool `json:"output_truncated"`
}
func (q *Queries) CreateTaskMessage(ctx context.Context, arg CreateTaskMessageParams) (TaskMessage, error) {
row := q.db.QueryRow(ctx, createTaskMessage,
arg.ID,
arg.TaskID,
arg.Seq,
arg.Type,
arg.Tool,
arg.Content,
arg.Input,
arg.Output,
arg.OutputTruncated,
)
var i TaskMessage
err := row.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
)
return i, err
}
const createTaskMessages = `-- name: CreateTaskMessages :many
WITH incoming AS (
-- Several single-argument unnest calls in one SELECT list expand in
-- lockstep (PostgreSQL 10+ set-returning-function semantics), which is the
-- same row-wise zip the multi-argument unnest(a, b, ...) form gives — but
-- sqlc's analyzer only knows the single-argument signature, so this is the
-- shape that survives code generation.
SELECT
unnest($1::uuid[]) AS id,
unnest($2::int4[]) AS seq,
unnest($3::text[]) AS type,
unnest($4::text[]) AS tool,
unnest($5::text[]) AS content,
unnest($6::text[]) AS input,
unnest($7::text[]) AS output,
unnest($8::text[]) AS output_truncated
), inserted AS (
INSERT INTO task_message (id, task_id, seq, type, tool, content, input, output, output_truncated)
SELECT
m.id,
$9::uuid,
m.seq,
m.type,
NULLIF(m.tool, ''),
NULLIF(m.content, ''),
NULLIF(m.input, '')::jsonb,
NULLIF(m.output, ''),
NULLIF(m.output_truncated, '')::bool
FROM incoming AS m
RETURNING id, task_id, seq, type, tool, content, input, output, created_at, output_truncated
)
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated FROM inserted ORDER BY seq ASC
`
type CreateTaskMessagesParams struct {
Ids []pgtype.UUID `json:"ids"`
Seqs []int32 `json:"seqs"`
Types []string `json:"types"`
Tools []string `json:"tools"`
Contents []string `json:"contents"`
Inputs []string `json:"inputs"`
Outputs []string `json:"outputs"`
OutputTruncations []string `json:"output_truncations"`
TaskID pgtype.UUID `json:"task_id"`
}
type CreateTaskMessagesRow struct {
ID pgtype.UUID `json:"id"`
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
Type string `json:"type"`
Tool pgtype.Text `json:"tool"`
Content pgtype.Text `json:"content"`
Input []byte `json:"input"`
Output pgtype.Text `json:"output"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OutputTruncated pgtype.Bool `json:"output_truncated"`
}
// Batch variant of CreateTaskMessage: persists a whole daemon-reported batch in
// ONE statement — therefore one round trip and, more importantly, one commit
// instead of one per message. Commit acknowledgement (IO:XactSync) is ~94% of
// this SQL's load in production, so the commit count is the thing being
// optimized; the round trip is a bonus.
//
// The rows arrive as parallel arrays rather than as one jsonb document, even
// though a jsonb document is the tidier Go side. content and output routinely
// carry tens of KB and occasionally megabytes, and wrapping them in JSON makes
// the server escape every byte a second time and Postgres parse the whole
// envelope back out — measured at 1.2x the old per-row insert at 128KB and
// ~1.8x at 1MB, i.e. a regression on exactly the most expensive requests, which
// are also the ones least likely to be batched. Native text[] elements are
// length-prefixed by the wire protocol, so they cost neither pass.
//
// NULLIF is what makes per-row NULL expressible through a non-nullable []string
// (the Go type sqlc gives a text[] parameter): it reproduces, exactly, the
// `pgtype.Text{Valid: x != ""}` mapping the single-row query carries — empty
// string means SQL NULL. input is passed as text and cast here for the same
// reason; it is the one column that genuinely has to be parsed as JSON, because
// it is a jsonb column. output_truncated rides the same trick for the same
// reason: it is a genuinely tri-state boolean (NULL = the reporting daemon did
// not measure it), and a bool[] parameter becomes a Go []bool, which has no way
// to spell the third state.
//
// Callers MUST still run the Postgres text sanitizer first. A NUL anywhere in
// the batch fails the whole statement (GH #7098) — that is inherent to batching
// into one statement, not to the parameter shape.
//
// Atomicity is a deliberate side effect, not just a speedup: the per-message
// loop this replaces could persist part of a batch and then fail, leaving the
// transcript with a prefix of the batch and no way to complete it — the daemon
// does not retry this endpoint. One statement makes the batch all-or-nothing,
// which buys consistency; a batch that fails is still lost whole, so closing
// the gap for real needs a retry plus a (task_id, seq) uniqueness rule.
//
// The ORDER BY is a contract, not decoration. A bare `INSERT ... RETURNING`
// has no defined row order, and the caller republishes these rows as realtime
// events in the order they arrive — the per-row loop this replaces implicitly
// published in request order, so the ordering has to be restored explicitly or
// subscribers can see a batch out of order. seq is assigned by the daemon and
// increases within a batch, so it is the request order.
func (q *Queries) CreateTaskMessages(ctx context.Context, arg CreateTaskMessagesParams) ([]CreateTaskMessagesRow, error) {
rows, err := q.db.Query(ctx, createTaskMessages,
arg.Ids,
arg.Seqs,
arg.Types,
arg.Tools,
arg.Contents,
arg.Inputs,
arg.Outputs,
arg.OutputTruncations,
arg.TaskID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CreateTaskMessagesRow{}
for rows.Next() {
var i CreateTaskMessagesRow
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteTaskMessages = `-- name: DeleteTaskMessages :exec
DELETE FROM task_message
WHERE task_id = $1
`
func (q *Queries) DeleteTaskMessages(ctx context.Context, taskID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTaskMessages, taskID)
return err
}
const listTaskMessages = `-- name: ListTaskMessages :many
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated FROM task_message
WHERE task_id = $1
ORDER BY seq ASC
`
func (q *Queries) ListTaskMessages(ctx context.Context, taskID pgtype.UUID) ([]TaskMessage, error) {
rows, err := q.db.Query(ctx, listTaskMessages, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskMessage{}
for rows.Next() {
var i TaskMessage
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTaskMessagesSince = `-- name: ListTaskMessagesSince :many
SELECT id, task_id, seq, type, tool, content, input, output, created_at, output_truncated FROM task_message
WHERE task_id = $1 AND seq > $2
ORDER BY seq ASC
`
type ListTaskMessagesSinceParams struct {
TaskID pgtype.UUID `json:"task_id"`
Seq int32 `json:"seq"`
}
func (q *Queries) ListTaskMessagesSince(ctx context.Context, arg ListTaskMessagesSinceParams) ([]TaskMessage, error) {
rows, err := q.db.Query(ctx, listTaskMessagesSince, arg.TaskID, arg.Seq)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TaskMessage{}
for rows.Next() {
var i TaskMessage
if err := rows.Scan(
&i.ID,
&i.TaskID,
&i.Seq,
&i.Type,
&i.Tool,
&i.Content,
&i.Input,
&i.Output,
&i.CreatedAt,
&i.OutputTruncated,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}