--- /dev/null
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// layoutFileName is the file of the view layout in the directory of an agent.
+const layoutFileName = "layout.json"
+
+// viewLayout splits the view of a turn request into user messages that only
+// grow between batches. The ChatGPT backend writes a cache entry only at the
+// end of the latest message of a request, and a later request reads it only at
+// one of its own message ends. So the view goes out as the same messages as in
+// the request before, while their lines give the same text, and the new lines
+// follow as one new message. Then the end of each earlier view stays a message
+// end, and a warm-up entry there stays readable when the view grows.
+//
+// The first message starts with "<chat>\n". The closing tag goes into the
+// state message after the view, so that no view message changes when lines
+// come. The layout keeps the line count and a hash of the text of each message
+// in layout.json, so it outlives a restart.
+type viewLayout struct {
+ path string
+ Messages []layoutMessage `json:"messages"`
+}
+
+// layoutMessage is one view message: its number of lines and the hash of its
+// text.
+type layoutMessage struct {
+ Lines int `json:"lines"`
+ Hash string `json:"hash"`
+}
+
+// loadLayout reads the layout at path. A missing file gives an empty layout.
+func loadLayout(path string) (*viewLayout, error) {
+ layout := &viewLayout{
+ path: path,
+ }
+
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return layout, nil
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("read view layout: %w", err)
+ }
+
+ err = json.Unmarshal(data, layout)
+ if err != nil {
+ return nil, fmt.Errorf("decode view layout: %w", err)
+ }
+
+ return layout, nil
+}
+
+// Split returns the view messages for lines and saves the layout. It keeps the
+// earlier messages up to the first one whose text differs, and puts the rest
+// of the lines into one new message.
+func (l *viewLayout) Split(lines []string) ([]string, error) {
+ var texts []string
+ var kept []layoutMessage
+ start := 0
+ for _, message := range l.Messages {
+ if start+message.Lines > len(lines) {
+ break
+ }
+
+ text := viewMessage(len(texts) == 0, lines[start:start+message.Lines])
+ if textHash(text) != message.Hash {
+ break
+ }
+
+ texts = append(texts, text)
+ kept = append(kept, message)
+ start += message.Lines
+ }
+
+ if start < len(lines) || len(texts) == 0 {
+ text := viewMessage(len(texts) == 0, lines[start:])
+ texts = append(texts, text)
+ kept = append(kept, layoutMessage{
+ Lines: len(lines) - start,
+ Hash: textHash(text),
+ })
+ }
+
+ l.Messages = kept
+ data, err := json.Marshal(l)
+ if err != nil {
+ return nil, fmt.Errorf("encode view layout: %w", err)
+ }
+
+ err = writeFileAtomic(l.path, append(data, '\n'))
+ if err != nil {
+ return nil, fmt.Errorf("save view layout: %w", err)
+ }
+
+ return texts, nil
+}
+
+// viewMessage returns the text of a view message with lines. The first one
+// opens the chat.
+func viewMessage(first bool, lines []string) string {
+ text := strings.Join(lines, "")
+ if first {
+ text = "<chat>\n" + text
+ }
+
+ return text
+}
+
+// textHash returns a short hash of text.
+func textHash(text string) string {
+ sum := sha256.Sum256([]byte(text))
+ return hex.EncodeToString(sum[:16])
+}
--- /dev/null
+package main
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestViewLayoutKeepsTheMessagesItSent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), layoutFileName)
+ layout, err := loadLayout(path)
+ require.NoError(t, err)
+
+ texts, err := layout.Split(nil)
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n"}, texts)
+
+ texts, err = layout.Split([]string{"0+1|a\n", "1+1|b\n"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n", "0+1|a\n1+1|b\n"}, texts)
+
+ // The same lines give the same messages, also after a restart.
+ layout, err = loadLayout(path)
+ require.NoError(t, err)
+
+ texts, err = layout.Split([]string{"0+1|a\n", "1+1|b\n"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n", "0+1|a\n1+1|b\n"}, texts)
+
+ texts, err = layout.Split([]string{"0+1|a\n", "1+1|b\n", "2+1|c\n"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n", "0+1|a\n1+1|b\n", "2+1|c\n"}, texts)
+}
+
+func TestViewLayoutStartsAgainAtAChangedMessage(t *testing.T) {
+ layout, err := loadLayout(filepath.Join(t.TempDir(), layoutFileName))
+ require.NoError(t, err)
+
+ _, err = layout.Split([]string{"0+1|a\n"})
+ require.NoError(t, err)
+
+ _, err = layout.Split([]string{"0+1|a\n", "1+1|b\n", "2+1|c\n"})
+ require.NoError(t, err)
+
+ // A batch merges lines 1 and 2, so the second message changes, and the
+ // rest becomes one new message.
+ texts, err := layout.Split([]string{"0+1|a\n", "1+2|b c\n", "3+1|d\n"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n0+1|a\n", "1+2|b c\n3+1|d\n"}, texts)
+
+ // A view that lost lines starts again with one message.
+ texts, err = layout.Split([]string{"0+4|all\n"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"<chat>\n0+4|all\n"}, texts)
+}
return m.chat.Render()
}
-// ViewWithout returns the chat view without the leaf lines of the messages in
-// skip.
-func (m *Memory) ViewWithout(skip map[int]bool) string {
+// ViewLines returns the lines of the chat view, each with its newline, without
+// the leaf lines of the messages in skip.
+func (m *Memory) ViewLines(skip map[int]bool) []string {
m.mutex.Lock()
defer m.mutex.Unlock()
- return m.chat.RenderWithout(skip)
+ return m.chat.LinesWithout(skip)
}
// Len returns the number of messages in the log.
// Request is one model call. Input holds the input items in order, such as
// userMessage values or output items of an earlier response. Node names the
-// node that a compaction builds, or is nil. It goes only to the usage file,
-// never to the endpoint.
+// node that a compaction builds, or is nil. Turn names the request of a turn:
+// first, next, or warmup. Node and Turn go only to the usage file, never to the
+// endpoint. A call with StopAtOutput ends at the first output item, after the
+// endpoint has read the whole input, and gives no output and no usage.
type Request struct {
Model string
Effort string
Tools []Tool
Input []any
Node *nodeKey
+ Turn string
+ StopAtOutput bool
}
// Tool is the definition of a function tool.
record.Node = &[2]int{request.Node.level, request.Node.index}
}
- response, err := c.send(httpRequest, &record)
+ record.Turn = request.Turn
+ response, err := c.send(httpRequest, &record, request.StopAtOutput)
if err != nil {
record.Error = err.Error()
}
// send makes the HTTP call and reads its stream. It puts the status and the
// quota headers into record.
-func (c *Client) send(httpRequest *http.Request, record *usageRecord) (response Response, err error) {
+func (c *Client) send(httpRequest *http.Request, record *usageRecord, stopAtOutput bool) (response Response, err error) {
httpResponse, err := c.http.Do(httpRequest)
if err != nil {
return Response{}, fmt.Errorf("send model call: %w", err)
return Response{}, errors.Join(err, readErr)
}
- return readStream(httpResponse.Body)
+ return readStream(httpResponse.Body, stopAtOutput)
}
// usageLog appends one JSON line for each model call to usage.jsonl. Calls can
// usageRecord is one line of usage.jsonl. Date is the start of the call, and
// Seconds its length. Node is the node [l, i] of a compaction. Status is the
// HTTP status, or 0 when no response came. TextBytes is the size of the text of
-// the reply. Quota holds the x-codex-* headers of the response, without the
+// the reply. Turn names the request of a turn. Quota holds the x-codex-* headers of the response, without the
// turn state.
type usageRecord struct {
Date string `json:"date"`
Seconds float64 `json:"seconds"`
Model string `json:"model"`
Node *[2]int `json:"node,omitempty"`
+ Turn string `json:"turn,omitempty"`
Status int `json:"status"`
InputTokens int `json:"input_tokens"`
CachedTokens int `json:"cached_tokens"`
// readStream reads the server-sent events of a call until the response
// completes. The output items come from the output_item.done events, or from
-// the completed response when the stream sent none.
-func readStream(stream io.Reader) (Response, error) {
+// the completed response when the stream sent none. With stopAtOutput it ends
+// at the first output item.
+func readStream(stream io.Reader, stopAtOutput bool) (Response, error) {
var response Response
reader := bufio.NewReader(stream)
var data bytes.Buffer
data.WriteByte('\n')
}
} else if data.Len() > 0 {
- done, eventErr := readEvent(&response, data.Bytes())
+ done, eventErr := readEvent(&response, data.Bytes(), stopAtOutput)
data.Reset()
if eventErr != nil || done {
return response, eventErr
// readEvent adds one stream event to response, and tells if it ends the
// stream.
-func readEvent(response *Response, data []byte) (bool, error) {
+func readEvent(response *Response, data []byte, stopAtOutput bool) (bool, error) {
var event streamEvent
err := json.Unmarshal(data, &event)
if err != nil {
}
switch event.Type {
+ case "response.output_item.added":
+ return stopAtOutput, nil
case "response.output_item.done":
response.Output = append(response.Output, event.Item)
case "response.completed":
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}
+func TestClientStopsAtTheFirstOutputItem(t *testing.T) {
+ server := newFakeResponses(t)
+ server.reply = func(w http.ResponseWriter, call fakeCall) {
+ writeEvents(w,
+ map[string]any{"type": "response.created"},
+ map[string]any{"type": "response.output_item.added", "item": messageItem("")},
+ )
+ }
+
+ client := newTestClient(t, server)
+ request := testRequest()
+ request.StopAtOutput = true
+ request.Turn = "warmup"
+ response, err := client.Create(t.Context(), request)
+ require.NoError(t, err)
+ require.Empty(t, response.Output)
+
+ request.StopAtOutput = false
+ _, err = client.Create(t.Context(), request)
+ require.ErrorContains(t, err, "event stream ended before the response completed")
+}
+
func TestClientRefusesCallsOverTheLimit(t *testing.T) {
server := newFakeResponses(t)
client := newTestClient(t, server)
"encoding/json"
"errors"
"fmt"
+ "path/filepath"
"strings"
"sync"
"time"
// input of turnInput. The loop runs the tool calls of each response and sends
// their results back, until a response has no tool call: the final answer.
//
+// The view goes out as the messages of a viewLayout. After the turns, when no
+// message waits, a warm-up request sends only the prompt, the tools, and the
+// view messages, and stops at its first output item. It writes a cache entry
+// at the end of the view, where the next turn has a message end, so the first
+// request of that turn reads the view from the cache. The warm-up is not
+// logged, and its output is not used.
+//
// Each later request of a turn begins with the whole input of the request
// before it, then its output items, then the tool results. The output items
// hold the reasoning with its encrypted content, because the calls do not
memory *Memory
client modelClient
tools *toolbox
+ layout *viewLayout
+ warmUp bool
clock func() time.Time
notify func(Event)
wake chan struct{}
Output string `json:"output"`
}
-// newLoop returns the loop of agent with its memory. Its calls go through
-// client, and notify gets each event. The clock gives the date of a turn.
-func newLoop(agent *Agent, memory *Memory, client modelClient, clock func() time.Time, notify func(Event)) *loop {
+// newLoop returns the loop of agent with its memory, with the warm-up on. Its
+// calls go through client, and notify gets each event. The clock gives the
+// date of a turn.
+func newLoop(agent *Agent, memory *Memory, client modelClient, clock func() time.Time, notify func(Event)) (*loop, error) {
+ layout, err := loadLayout(filepath.Join(agent.dir, layoutFileName))
+ if err != nil {
+ return nil, err
+ }
+
l := &loop{
agent: agent,
memory: memory,
client: client,
+ layout: layout,
+ warmUp: true,
clock: clock,
notify: notify,
wake: make(chan struct{}, 1),
workdir: agent.config.Workdir,
}
- return l
+ return l, nil
}
// Submit logs a user message and wakes the loop. The message joins the next
return l.memory.Flush(ctx)
}
-// Run runs a turn each time user messages wait, until ctx ends. It returns the
-// error of a failed log write, which stops the agent.
+// Run runs a turn each time user messages wait, and a warm-up after the turns,
+// until ctx ends. It returns the error of a failed log write, which stops the
+// agent. A failed warm-up only costs the cache of the next turn, so its error
+// stays in the usage file.
func (l *loop) Run(ctx context.Context) error {
for {
+ turned := false
for l.waiting() {
err := l.turn(ctx)
if err != nil {
return err
}
+
+ turned = true
+ }
+
+ if turned && l.warmUp {
+ _ = l.warm(ctx)
}
select {
skip[message.ID] = true
}
- input := turnInput(l.memory.ViewWithout(skip), l.state(), messages)
- for {
+ view, err := l.layout.Split(l.memory.ViewLines(skip))
+ if err != nil {
+ return err
+ }
+
+ input := turnInput(view, l.state(), messages)
+ for turn := "first"; ; turn = "next" {
response, err := l.client.Create(ctx, Request{
Model: l.agent.config.Model,
Effort: l.agent.config.Effort,
Instructions: instructions,
Tools: agentTools(),
Input: input,
+ Turn: turn,
})
if err != nil {
return err
return input, nil
}
-// state returns the per-turn state: the date and time of the turn.
+// warm sends the warm-up request, when the builds are done and no message
+// waits.
+func (l *loop) warm(ctx context.Context) error {
+ err := l.memory.Wait(ctx)
+ if err != nil || l.waiting() {
+ return err
+ }
+
+ instructions, err := l.agent.Prompt()
+ if err != nil {
+ return err
+ }
+
+ view, err := l.layout.Split(l.memory.ViewLines(nil))
+ if err != nil {
+ return err
+ }
+
+ _, err = l.client.Create(ctx, Request{
+ Model: l.agent.config.Model,
+ Effort: l.agent.config.Effort,
+ Instructions: instructions,
+ Tools: agentTools(),
+ Input: warmUpInput(view),
+ Turn: "warmup",
+ StopAtOutput: true,
+ })
+
+ return err
+}
+
+// state returns the per-turn state: the end of the view, and the date and time
+// of the turn.
func (l *loop) state() string {
- return "Now: " + l.clock().Format("Monday 2006-01-02 15:04 -07:00")
+ return "</chat>\n\nNow: " + l.clock().Format("Monday 2006-01-02 15:04 -07:00")
}
-// turnInput returns the input of the first request of a turn: one user message
-// with the view, the per-turn state, and each new message whole. The view
-// leaves out the leaf lines of the new messages, which come whole after it.
-func turnInput(view, state string, messages []Message) []any {
- parts := []string{view + "\n", state + "\n"}
+// turnInput returns the input of the first request of a turn, stable parts
+// first: a user message for each view message, one with the per-turn state,
+// and one for each new message, whole. The view leaves out the leaf lines of
+// the new messages.
+func turnInput(view []string, state string, messages []Message) []any {
+ input := warmUpInput(view)
+ input = append(input, userMessage(state))
for _, message := range messages {
- parts = append(parts, message.Text)
+ input = append(input, userMessage(message.Text))
+ }
+
+ return input
+}
+
+// warmUpInput returns a user message for each view message.
+func warmUpInput(view []string) []any {
+ input := make([]any, len(view))
+ for n, text := range view {
+ input[n] = userMessage(text)
}
- return []any{userMessage(parts...)}
+ return input
}
// inputItem returns an output item as an input item for the next request. The
import (
"context"
+ "encoding/json"
"errors"
"net/http"
+ "path/filepath"
"strings"
"sync"
"testing"
test.events.WaitFor(t, "idle", 1)
require.Equal(t, []string{
- "<chat>\n0+1|user: an earlier message\n</chat>\n",
- "Now: Thursday 2026-01-01 12:00 +00:00\n",
+ "<chat>\n0+1|user: an earlier message\n",
+ "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00",
"The new message.",
- }, inputParts(t, server.Call(0)))
+ }, inputTexts(t, server.Call(0)))
}
func TestMessageDuringATurnJoinsTheNextRequest(t *testing.T) {
test.events.WaitFor(t, "idle", 2)
require.Equal(t, 2, server.Calls())
- parts := inputParts(t, server.Call(1))
- require.Equal(t, "second", parts[len(parts)-1])
- require.Contains(t, parts[0], "|reply: answer\n")
+ texts := inputTexts(t, server.Call(1))
+ require.Equal(t, "second", texts[len(texts)-1])
+ require.Contains(t, strings.Join(texts, ""), "|reply: answer\n")
}
func TestTurnWaitsForTheSummaries(t *testing.T) {
server := newFakeResponses(t)
summarizer := newFakeSummarizer()
summarizer.gate = make(chan struct{})
- test := newTestLoopWith(t, server, summarizer)
+ test := newTestLoopWith(t, server, summarizer, false)
_, err := test.memory.Append("echo", strings.Repeat("long output ", 100))
require.NoError(t, err)
close(summarizer.gate)
test.events.WaitFor(t, "idle", 1)
require.Equal(t, 1, server.Calls())
- require.NotContains(t, inputParts(t, server.Call(0))[0], pending)
+ require.NotContains(t, inputTexts(t, server.Call(0))[0], pending)
}
func TestInterruptEndsTheTurn(t *testing.T) {
require.Contains(t, test.events.Last("error"), "HTTP 503")
}
+func TestViewMessagesOnlyGrowBetweenTurns(t *testing.T) {
+ server := newFakeResponses(t)
+ test := newTestLoop(t, server)
+ for n, text := range []string{"one", "two", "three"} {
+ _, err := test.loop.Submit(text)
+ require.NoError(t, err)
+ test.events.WaitFor(t, "idle", n+1)
+ }
+
+ first := server.Call(1).body["input"].([]any)
+ second := server.Call(2).body["input"].([]any)
+ require.Len(t, first, 4)
+ require.Len(t, second, 5)
+ require.Equal(t, first[:2], second[:2])
+ require.Equal(t, []string{
+ "<chat>\n",
+ "0+1|user: one\n1+1|reply: a line\n",
+ "2+1|user: two\n3+1|reply: a line\n",
+ "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00",
+ "three",
+ }, inputTexts(t, server.Call(2)))
+}
+
+func TestWarmUpEndsWhereTheNextTurnReadsTheCache(t *testing.T) {
+ server := newFakeResponses(t)
+ server.reply = func(w http.ResponseWriter, call fakeCall) {
+ if call.number == 1 {
+ writeEvents(w, map[string]any{"type": "response.output_item.added", "item": reasoningItem("rs_1", "")})
+ return
+ }
+
+ writeEvents(w, completedEvent([]any{messageItem("answer")}, 100, 0, 5))
+ }
+
+ test := newTestLoopWith(t, server, newFakeSummarizer(), true)
+ _, err := test.loop.Submit("one")
+ require.NoError(t, err)
+ require.Eventually(t, func() bool {
+ return server.Calls() == 2
+ }, 10*time.Second, time.Millisecond)
+
+ _, err = test.loop.Submit("two")
+ require.NoError(t, err)
+ test.events.WaitFor(t, "idle", 2)
+ require.Eventually(t, func() bool {
+ return server.Calls() == 4
+ }, 10*time.Second, time.Millisecond)
+
+ warmUp := server.Call(1).body
+ require.Equal(t, server.Call(0).body["instructions"], warmUp["instructions"])
+ require.Equal(t, server.Call(0).body["tools"], warmUp["tools"])
+ require.Equal(t, []string{"<chat>\n", "0+1|user: one\n1+1|reply: answer\n"}, inputTexts(t, server.Call(1)))
+
+ // The next turn begins with the whole warm-up, so its message end after
+ // the view is the end of the warm-up entry.
+ warmInput := warmUp["input"].([]any)
+ turnInput := server.Call(2).body["input"].([]any)
+ require.Equal(t, warmInput, turnInput[:len(warmInput)])
+ require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", inputTexts(t, server.Call(2))[len(warmInput)])
+
+ turns := []string{}
+ for _, record := range readUsage(t, test.usagePath) {
+ turns = append(turns, record.Turn)
+ }
+
+ require.Equal(t, []string{"first", "warmup", "first", "warmup"}, turns)
+ require.Equal(t, []string{"user: one", "reply: answer", "user: two", "reply: answer"}, test.Log(t))
+}
+
// testLoop is a loop that runs in the background of a test, with its parts.
type testLoop struct {
- agent *Agent
- memory *Memory
- loop *loop
- events *eventRecorder
+ agent *Agent
+ memory *Memory
+ loop *loop
+ events *eventRecorder
+ usagePath string
}
// Log returns the logged messages as "kind: text".
func newTestLoop(t *testing.T, server *fakeResponses) *testLoop {
t.Helper()
- return newTestLoopWith(t, server, newFakeSummarizer())
+ return newTestLoopWith(t, server, newFakeSummarizer(), false)
}
// newTestLoopWith returns a running loop on a new agent whose builds use
-// summarizer and whose commands run in a temporary directory.
-func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) *testLoop {
+// summarizer and whose commands run in a temporary directory. warmUp turns the
+// warm-up on.
+func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer, warmUp bool) *testLoop {
t.Helper()
agent, err := openAgent(t.TempDir())
require.NoError(t, err)
test := &testLoop{
- agent: agent,
- memory: memory,
- events: &eventRecorder{},
+ agent: agent,
+ memory: memory,
+ events: &eventRecorder{},
+ usagePath: filepath.Join(t.TempDir(), "usage.jsonl"),
}
- test.loop = newLoop(agent, memory, newTestClient(t, server), clock.Now, test.events.Add)
+ client := newClient(server.URL, apiKey(testKey), agent.CacheKey(), test.usagePath)
+ test.loop, err = newLoop(agent, memory, client, clock.Now, test.events.Add)
+ require.NoError(t, err)
+
+ test.loop.warmUp = warmUp
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error)
go func() {
return test
}
+// inputTexts returns the text of each user message of the input of call.
+func inputTexts(t *testing.T, call fakeCall) []string {
+ t.Helper()
+
+ var body struct {
+ Input []struct {
+ Role string `json:"role"`
+ Content []struct {
+ Text string `json:"text"`
+ } `json:"content"`
+ } `json:"input"`
+ }
+
+ require.NoError(t, json.Unmarshal(call.raw, &body))
+
+ var texts []string
+ for _, item := range body.Input {
+ if item.Role != "user" {
+ continue
+ }
+
+ var text strings.Builder
+ for _, part := range item.Content {
+ text.WriteString(part.Text)
+ }
+
+ texts = append(texts, text.String())
+ }
+
+ return texts
+}
+
func reasoningItem(id, encrypted string) map[string]any {
return map[string]any{
"type": "reasoning",
// Render returns the view as the model reads it: one line "id+n|text" for each
// node inside <chat> tags, with the newlines of the text as spaces.
func (v *View) Render() string {
- return v.RenderWithout(nil)
+ return "<chat>\n" + strings.Join(v.LinesWithout(nil), "") + "</chat>"
}
-// RenderWithout returns the view as Render does, without the leaf lines of the
-// messages in skip. A line that merges such a message with others stays.
-func (v *View) RenderWithout(skip map[int]bool) string {
- var text strings.Builder
- text.WriteString("<chat>\n")
+// LinesWithout returns the lines of the view as the model reads them, each with
+// its newline, without the leaf lines of the messages in skip. A line that
+// merges such a message with others stays.
+func (v *View) LinesWithout(skip map[int]bool) []string {
+ lines := make([]string, 0, len(v.lines))
for _, line := range v.lines {
if line.level == 0 && skip[line.index] {
continue
}
+ var text strings.Builder
v.renderLine(&text, line)
+ lines = append(lines, text.String())
}
- text.WriteString("</chat>")
-
- return text.String()
+ return lines
}
// renderLine writes one view line, "id+n|text", and a newline.