fix(pi): use stdout session header (#126)
This commit is contained in:
@@ -10,7 +10,7 @@ Provide the **`pi`** engine backend so Takopi can:
|
|||||||
|
|
||||||
* Run Pi non-interactively via the **pi CLI** (`pi --print`).
|
* Run Pi non-interactively via the **pi CLI** (`pi --print`).
|
||||||
* Stream progress by parsing **`--mode json`** (newline-delimited JSON). Each line is a JSON object.
|
* Stream progress by parsing **`--mode json`** (newline-delimited JSON). Each line is a JSON object.
|
||||||
* Support resumable sessions via **`--session <path>`** (Takopi emits a canonical resume line the user can reply with).
|
* Support resumable sessions via **`--session <token>`** (Takopi emits a canonical resume line the user can reply with).
|
||||||
|
|
||||||
### Non-goals (v1)
|
### Non-goals (v1)
|
||||||
|
|
||||||
@@ -36,10 +36,10 @@ Takopi appends a **single backticked** resume line at the end of the message, li
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
* `pi --resume/-r` opens an interactive session picker, so Takopi uses `--session <path>` instead.
|
* `pi --resume/-r` opens an interactive session picker, so Takopi uses `--session <token>` instead.
|
||||||
* The resume token is the **session id** (short prefix), derived from the first JSON
|
* The resume token is the **session id** (short prefix), derived from the session
|
||||||
object in the session file. If the id cannot be read, Takopi falls back to the
|
header line (`{"type":"session", ...}`) emitted to stdout in `--mode json`.
|
||||||
session file path.
|
This requires **pi-coding-agent >= 0.45.1**.
|
||||||
* If the path contains spaces, the runner will quote it.
|
* If the path contains spaces, the runner will quote it.
|
||||||
|
|
||||||
### Non-interactive runs
|
### Non-interactive runs
|
||||||
@@ -91,7 +91,7 @@ The runner should launch Pi in headless JSON mode:
|
|||||||
pi --print --mode json --session <session.jsonl> <prompt>
|
pi --print --mode json --session <session.jsonl> <prompt>
|
||||||
```
|
```
|
||||||
|
|
||||||
When resuming, `<session.jsonl>` is the resume token extracted from the chat.
|
When resuming, `<session.jsonl>` is replaced by the resume token extracted from the chat.
|
||||||
|
|
||||||
#### Event translation
|
#### Event translation
|
||||||
|
|
||||||
@@ -116,6 +116,8 @@ Install the CLI globally:
|
|||||||
npm install -g @mariozechner/pi-coding-agent
|
npm install -g @mariozechner/pi-coding-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Minimum supported pi version: **0.45.1**.
|
||||||
|
|
||||||
Auth is stored under `~/.pi/agent/auth.json`. Run `pi` once interactively to
|
Auth is stored under `~/.pi/agent/auth.json`. Run `pi` once interactively to
|
||||||
set up credentials before using Takopi.
|
set up credentials before using Takopi.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ required `type` field. These are `AgentSessionEvent` objects from
|
|||||||
|
|
||||||
## Top-level event lines
|
## Top-level event lines
|
||||||
|
|
||||||
|
### `session` (header, pi >= 0.45.1)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"session","id":"ccd569e0-4e1b-4c7d-a981-637ed4107310","version":3,"timestamp":"2026-01-13T00:33:34.702Z","cwd":"/repo"}
|
||||||
|
```
|
||||||
|
|
||||||
### `agent_start`
|
### `agent_start`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ Notes:
|
|||||||
`pi --session <id>`
|
`pi --session <id>`
|
||||||
```
|
```
|
||||||
|
|
||||||
The token is the **short session id**, derived from the first JSON object in the
|
The token is the **short session id**, derived from the session header line
|
||||||
session file. If the id cannot be read, Takopi falls back to the session file path.
|
(`{"type":"session", ...}`) emitted on stdout when running in `--mode json`.
|
||||||
|
This requires **pi-coding-agent >= 0.45.1**.
|
||||||
|
|
||||||
Why not `--resume`?
|
Why not `--resume`?
|
||||||
- `--resume/-r` opens an interactive session picker; it does not accept a
|
- `--resume/-r` opens an interactive session picker; it does not accept a
|
||||||
session token. Takopi must use `--session <path>` instead.
|
session token. Takopi must use `--session <token>` instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,8 +48,9 @@ Why not `--resume`?
|
|||||||
Takopi requires **serialization per session token**:
|
Takopi requires **serialization per session token**:
|
||||||
|
|
||||||
- For new runs (`resume=None`), do **not** acquire a lock until a `started`
|
- For new runs (`resume=None`), do **not** acquire a lock until a `started`
|
||||||
event is emitted (Takopi emits this as soon as the first JSON event arrives).
|
event is emitted (Takopi emits this as soon as the session header or first
|
||||||
- Once the session is known, acquire a lock for `pi:<session_path>` and hold it
|
JSON event arrives).
|
||||||
|
- Once the session is known, acquire a lock for `pi:<session_token>` and hold it
|
||||||
until the run completes.
|
until the run completes.
|
||||||
- For resumed runs, acquire the lock immediately on entry.
|
- For resumed runs, acquire the lock immediately on entry.
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ Mapping:
|
|||||||
- `ok = true` unless the last assistant message has `stopReason` `error` or `aborted`.
|
- `ok = true` unless the last assistant message has `stopReason` `error` or `aborted`.
|
||||||
- `answer = last assistant text` (from `message_end` or `agent_end.messages`).
|
- `answer = last assistant text` (from `message_end` or `agent_end.messages`).
|
||||||
- `error = errorMessage` if present.
|
- `error = errorMessage` if present.
|
||||||
- `resume = ResumeToken(engine="pi", value=session_path)`.
|
- `resume = ResumeToken(engine="pi", value=session_token)`.
|
||||||
- `usage = last assistant usage`.
|
- `usage = last assistant usage`.
|
||||||
|
|
||||||
### 4.5 Other events
|
### 4.5 Other events
|
||||||
|
|||||||
+24
-61
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
@@ -44,7 +43,6 @@ _SESSION_ID_PREFIX_LEN = 8
|
|||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PiStreamState:
|
class PiStreamState:
|
||||||
resume: ResumeToken
|
resume: ResumeToken
|
||||||
session_path: str | None = None
|
|
||||||
allow_id_promotion: bool = False
|
allow_id_promotion: bool = False
|
||||||
pending_actions: dict[str, Action] = field(default_factory=dict)
|
pending_actions: dict[str, Action] = field(default_factory=dict)
|
||||||
last_assistant_text: str | None = None
|
last_assistant_text: str | None = None
|
||||||
@@ -74,47 +72,17 @@ def _short_session_id(session_id: str) -> str:
|
|||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
def _session_id_from_line(line: str) -> str | None:
|
def _maybe_promote_session_id(state: PiStreamState, session_id: str | None) -> None:
|
||||||
try:
|
if not session_id:
|
||||||
data = json.loads(line)
|
return
|
||||||
except json.JSONDecodeError:
|
if state.started:
|
||||||
return None
|
return
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
event_type = data.get("type")
|
|
||||||
if event_type is not None and event_type != "session":
|
|
||||||
return None
|
|
||||||
session_id = data.get("id")
|
|
||||||
if isinstance(session_id, str) and session_id:
|
|
||||||
return _short_session_id(session_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _session_id_from_path(path: Path) -> str | None:
|
|
||||||
path = path.expanduser()
|
|
||||||
try:
|
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
|
||||||
for raw_line in handle:
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
return _session_id_from_line(line)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_promote_session_id(state: PiStreamState) -> None:
|
|
||||||
if not state.allow_id_promotion:
|
if not state.allow_id_promotion:
|
||||||
return
|
return
|
||||||
session_path = state.session_path
|
if not _looks_like_session_path(state.resume.value):
|
||||||
if not session_path:
|
|
||||||
return
|
return
|
||||||
if state.resume.value != session_path:
|
state.resume = ResumeToken(engine=ENGINE, value=_short_session_id(session_id))
|
||||||
return
|
state.allow_id_promotion = False
|
||||||
session_id = _session_id_from_path(Path(session_path))
|
|
||||||
if session_id:
|
|
||||||
state.resume = ResumeToken(engine=ENGINE, value=session_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _action_event(
|
def _action_event(
|
||||||
@@ -186,7 +154,20 @@ def translate_pi_event(
|
|||||||
state: PiStreamState,
|
state: PiStreamState,
|
||||||
) -> list[TakopiEvent]:
|
) -> list[TakopiEvent]:
|
||||||
out: list[TakopiEvent] = []
|
out: list[TakopiEvent] = []
|
||||||
_maybe_promote_session_id(state)
|
if isinstance(event, pi_schema.SessionHeader):
|
||||||
|
_maybe_promote_session_id(state, event.id)
|
||||||
|
if not state.started:
|
||||||
|
out.append(
|
||||||
|
StartedEvent(
|
||||||
|
engine=ENGINE,
|
||||||
|
resume=state.resume,
|
||||||
|
title=title,
|
||||||
|
meta=meta or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.started = True
|
||||||
|
return out
|
||||||
|
|
||||||
if not state.started:
|
if not state.started:
|
||||||
out.append(
|
out.append(
|
||||||
StartedEvent(
|
StartedEvent(
|
||||||
@@ -313,7 +294,7 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|||||||
def run(
|
def run(
|
||||||
self, prompt: str, resume: ResumeToken | None
|
self, prompt: str, resume: ResumeToken | None
|
||||||
) -> AsyncIterator[TakopiEvent]:
|
) -> AsyncIterator[TakopiEvent]:
|
||||||
return super().run(prompt, self._normalize_resume_token(resume))
|
return super().run(prompt, resume)
|
||||||
|
|
||||||
def extract_resume(self, text: str | None) -> ResumeToken | None:
|
def extract_resume(self, text: str | None) -> ResumeToken | None:
|
||||||
if not text:
|
if not text:
|
||||||
@@ -329,24 +310,8 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|||||||
found = token
|
found = token
|
||||||
if not found:
|
if not found:
|
||||||
return None
|
return None
|
||||||
if _looks_like_session_path(found):
|
|
||||||
session_id = _session_id_from_path(Path(found))
|
|
||||||
if session_id:
|
|
||||||
found = session_id
|
|
||||||
return ResumeToken(engine=self.engine, value=found)
|
return ResumeToken(engine=self.engine, value=found)
|
||||||
|
|
||||||
def _normalize_resume_token(self, resume: ResumeToken | None) -> ResumeToken | None:
|
|
||||||
if resume is None:
|
|
||||||
return None
|
|
||||||
if resume.engine != ENGINE:
|
|
||||||
return resume
|
|
||||||
if not _looks_like_session_path(resume.value):
|
|
||||||
return resume
|
|
||||||
session_id = _session_id_from_path(Path(resume.value))
|
|
||||||
if session_id:
|
|
||||||
return ResumeToken(engine=ENGINE, value=session_id)
|
|
||||||
return resume
|
|
||||||
|
|
||||||
def command(self) -> str:
|
def command(self) -> str:
|
||||||
return "pi"
|
return "pi"
|
||||||
|
|
||||||
@@ -387,11 +352,9 @@ class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|||||||
token = ResumeToken(engine=ENGINE, value=session_path)
|
token = ResumeToken(engine=ENGINE, value=session_path)
|
||||||
return PiStreamState(
|
return PiStreamState(
|
||||||
resume=token,
|
resume=token,
|
||||||
session_path=session_path,
|
|
||||||
allow_id_promotion=True,
|
allow_id_promotion=True,
|
||||||
)
|
)
|
||||||
session_path = resume.value if _looks_like_session_path(resume.value) else None
|
return PiStreamState(resume=resume)
|
||||||
return PiStreamState(resume=resume, session_path=session_path)
|
|
||||||
|
|
||||||
def translate(
|
def translate(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ class _Event(msgspec.Struct, tag_field="type", forbid_unknown_fields=False):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionHeader(_Event, tag="session"):
|
||||||
|
id: str | None = None
|
||||||
|
version: int | None = None
|
||||||
|
timestamp: str | None = None
|
||||||
|
cwd: str | None = None
|
||||||
|
parentSession: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentStart(_Event, tag="agent_start"):
|
class AgentStart(_Event, tag="agent_start"):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -85,7 +93,8 @@ class AutoRetryEnd(_Event, tag="auto_retry_end"):
|
|||||||
|
|
||||||
|
|
||||||
type PiEvent = (
|
type PiEvent = (
|
||||||
AgentStart
|
SessionHeader
|
||||||
|
| AgentStart
|
||||||
| AgentEnd
|
| AgentEnd
|
||||||
| MessageStart
|
| MessageStart
|
||||||
| MessageUpdate
|
| MessageUpdate
|
||||||
|
|||||||
+17
-37
@@ -99,41 +99,28 @@ def test_translate_error_fixture() -> None:
|
|||||||
assert completed.answer == "Request failed."
|
assert completed.answer == "Request failed."
|
||||||
|
|
||||||
|
|
||||||
def test_session_id_promotion_from_file(tmp_path: Path) -> None:
|
def test_session_id_promotion_from_stdout() -> None:
|
||||||
session_path = tmp_path / "session.jsonl"
|
state = PiStreamState(
|
||||||
session_path.write_text(
|
resume=ResumeToken(engine=ENGINE, value="session.jsonl"),
|
||||||
'{"type":"session","version":3,'
|
allow_id_promotion=True,
|
||||||
'"id":"ccd569e0-4e1b-4c7d-a981-637ed4107310",'
|
|
||||||
'"timestamp":"2026-01-13T00:33:34.702Z",'
|
|
||||||
'"cwd":"/tmp"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
runner = PiRunner(
|
|
||||||
extra_args=[],
|
|
||||||
model=None,
|
|
||||||
provider=None,
|
|
||||||
)
|
|
||||||
with patch(
|
|
||||||
"takopi.runners.pi.PiRunner._new_session_path",
|
|
||||||
return_value=str(session_path),
|
|
||||||
):
|
|
||||||
state = runner.new_state("prompt", None)
|
|
||||||
events = translate_pi_event(
|
events = translate_pi_event(
|
||||||
pi_schema.AgentStart(), title="pi", meta=None, state=state
|
pi_schema.SessionHeader(
|
||||||
|
id="ccd569e0-4e1b-4c7d-a981-637ed4107310",
|
||||||
|
version=3,
|
||||||
|
timestamp="2026-01-13T00:33:34.702Z",
|
||||||
|
cwd="/tmp",
|
||||||
|
),
|
||||||
|
title="pi",
|
||||||
|
meta=None,
|
||||||
|
state=state,
|
||||||
)
|
)
|
||||||
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
started = next(evt for evt in events if isinstance(evt, StartedEvent))
|
||||||
assert started.resume.value == "ccd569e0"
|
assert started.resume.value == "ccd569e0"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_resume_prefers_session_id(tmp_path: Path) -> None:
|
def test_extract_resume_keeps_session_path(tmp_path: Path) -> None:
|
||||||
session_path = tmp_path / "session.jsonl"
|
session_path = tmp_path / "session.jsonl"
|
||||||
session_path.write_text(
|
|
||||||
'{"type":"session","version":3,'
|
|
||||||
'"id":"ccd569e0-4e1b-4c7d-a981-637ed4107310",'
|
|
||||||
'"timestamp":"2026-01-13T00:33:34.702Z",'
|
|
||||||
'"cwd":"/tmp"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
runner = PiRunner(
|
runner = PiRunner(
|
||||||
extra_args=[],
|
extra_args=[],
|
||||||
model=None,
|
model=None,
|
||||||
@@ -141,19 +128,12 @@ def test_extract_resume_prefers_session_id(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
token = runner.extract_resume(f"pi --session {session_path}")
|
token = runner.extract_resume(f"pi --session {session_path}")
|
||||||
assert token is not None
|
assert token is not None
|
||||||
assert token.value == "ccd569e0"
|
assert token.value == str(session_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_run_normalizes_resume_path(tmp_path: Path) -> None:
|
async def test_run_keeps_resume_path(tmp_path: Path) -> None:
|
||||||
session_path = tmp_path / "session.jsonl"
|
session_path = tmp_path / "session.jsonl"
|
||||||
session_path.write_text(
|
|
||||||
'{"type":"session","version":3,'
|
|
||||||
'"id":"ccd569e0-4e1b-4c7d-a981-637ed4107310",'
|
|
||||||
'"timestamp":"2026-01-13T00:33:34.702Z",'
|
|
||||||
'"cwd":"/tmp"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
runner = PiRunner(
|
runner = PiRunner(
|
||||||
extra_args=[],
|
extra_args=[],
|
||||||
model=None,
|
model=None,
|
||||||
@@ -176,7 +156,7 @@ async def test_run_normalizes_resume_path(tmp_path: Path) -> None:
|
|||||||
async for _event in runner.run("test", resume):
|
async for _event in runner.run("test", resume):
|
||||||
pass
|
pass
|
||||||
assert seen_resume is not None
|
assert seen_resume is not None
|
||||||
assert seen_resume.value == "ccd569e0"
|
assert seen_resume.value == str(session_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
Reference in New Issue
Block a user