message-shape.md 3.2 KB

Message Shape

Problem:

  • stored messages need enough data to replay and resume a session later
  • prompt hooks often just want to append a synthetic user/assistant message
  • today that means faking ids, timestamps, and request metadata

Option 1: Two Message Shapes

Keep User / Assistant for stored history, but clean them up.

type User = {
  role: "user"
  time: { created: number }
  request: {
    agent: string
    model: ModelRef
    variant?: string
    format?: OutputFormat
    system?: string
    tools?: Record<string, boolean>
  }
}

type Assistant = {
  role: "assistant"
  run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
  usage: { cost: number; tokens: Tokens }
  result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}

Add a separate transient PromptMessage for prompt surgery.

type PromptMessage = {
  role: "user" | "assistant"
  parts: PromptPart[]
}

Plugin hook example:

prompt.push({
  role: "user",
  parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})

Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.

Option 2: Prompt Mutators

Keep User / Assistant as the stored history model.

Prompt hooks do not build messages directly. The runtime gives them prompt mutators.

type PromptEditor = {
  append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
  prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
  appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
  insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
  insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}

Plugin hook examples:

prompt.append({
  role: "user",
  parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])

Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.

Option 3: Separate Turn State

Move execution settings out of User and into a separate turn/request object.

type Turn = {
  id: string
  request: {
    agent: string
    model: ModelRef
    variant?: string
    format?: OutputFormat
    system?: string
    tools?: Record<string, boolean>
  }
}

type User = {
  role: "user"
  turnID: string
  time: { created: number }
}

type Assistant = {
  role: "assistant"
  turnID: string
  usage: { cost: number; tokens: Tokens }
  result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}

Examples:

const turn = {
  request: {
    agent: "build",
    model: { providerID: "openai", modelID: "gpt-5" },
  },
}
const msg = {
  role: "user",
  turnID: turn.id,
  parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}

Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.