From 92042bb2a3c4515cdfdb98fb2c0317f3526c2073 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 09:39:58 +0900 Subject: [PATCH] feat: edge node registry, opsconsole, CLI persistent, and e2e smoke updates - Refactor edge node registry and store for improved management - Update opsconsole console, events, and status components - Enhance edge service and transport layers - Improve CLI persistent adapter and output filter - Update e2e smoke test script - Update project and testing domain rules - Update Makefile and documentation --- Makefile | 1 + .../rules/project/domain/testing/rules.md | 75 ++++++-- agent-ops/rules/project/rules.md | 2 +- agent-ops/skills/project/e2e-smoke/SKILL.md | 170 ++++++++++------- apps/edge/README.md | 59 ++++-- apps/edge/internal/node/registry.go | 50 ++++- apps/edge/internal/node/store.go | 2 + apps/edge/internal/opsconsole/console.go | 12 +- apps/edge/internal/opsconsole/console_test.go | 4 +- apps/edge/internal/opsconsole/events.go | 74 +++++--- apps/edge/internal/opsconsole/events_test.go | 36 ++-- apps/edge/internal/opsconsole/status.go | 4 +- apps/edge/internal/service/service.go | 5 +- apps/edge/internal/service/service_test.go | 10 +- apps/edge/internal/transport/server.go | 10 +- apps/node/internal/adapters/cli/persistent.go | 8 +- .../cli/persistent_execute_blackbox_test.go | 55 +++++- .../adapters/cli/persistent_output_filter.go | 179 ++++++++++++++++-- .../cli/persistent_output_filter_test.go | 129 ++++++++++--- scripts/e2e-smoke.sh | 117 +++++++++--- 20 files changed, 756 insertions(+), 246 deletions(-) diff --git a/Makefile b/Makefile index c529e4b..d9aee08 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ test: go test ./... test-e2e: + @echo "NOTE: test-e2e runs auxiliary smoke only; completion requires bin/edge.sh + bin/node.sh user-flow verification." ./scripts/e2e-smoke.sh # Requires: protoc + protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest) diff --git a/agent-ops/rules/project/domain/testing/rules.md b/agent-ops/rules/project/domain/testing/rules.md index 9dca4bd..b82c27f 100644 --- a/agent-ops/rules/project/domain/testing/rules.md +++ b/agent-ops/rules/project/domain/testing/rules.md @@ -2,13 +2,13 @@ ## 목적 / 책임 -작업 완료 후 어떤 테스트와 E2E smoke 검증을 거쳐야 하는지 정리한다. 테스트 파일을 바꿨는지가 아니라, 변경 작업이 어떤 사용자 실행 파이프라인에 닿았는지를 기준으로 검증 범위를 정한다. +작업 완료 후 어떤 테스트와 bin shell 사용자 흐름 검증을 거쳐야 하는지 정리한다. 테스트 파일을 바꿨는지가 아니라, 변경 작업이 어떤 사용자 실행 파이프라인에 닿았는지를 기준으로 검증 범위를 정한다. ## 포함 경로 -- `Makefile` — 공식 test target과 향후 E2E smoke target을 선언하는 위치이다. -- `bin/edge.sh` — 사용자가 edge console/server를 실행하는 shell entrypoint이며 E2E smoke의 검증 대상이다. -- `bin/node.sh` — 사용자가 node를 edge에 연결하는 shell entrypoint이며 E2E smoke의 검증 대상이다. +- `Makefile` — 공식 test target과 보조 smoke target을 선언하는 위치이다. +- `bin/edge.sh` — 사용자가 edge console/server를 실행하는 shell entrypoint이며 bin shell 사용자 흐름 검증의 기준 대상이다. +- `bin/node.sh` — 사용자가 node를 edge에 연결하는 shell entrypoint이며 bin shell 사용자 흐름 검증의 기준 대상이다. ## 제외 경로 @@ -20,22 +20,72 @@ - 대상 패키지 테스트 — 변경한 패키지와 인접한 패키지의 빠른 회귀 검증이다. - `go test ./...` — 저장소 전체 Go 테스트 회귀 검증이다. -- `bin/edge.sh` + `bin/node.sh` E2E smoke — 사용자가 실행하는 entrypoint를 통해 edge-node 등록, console 입력, adapter 실행, event/message 출력을 빠르게 확인하는 검증이다. +- bin shell 사용자 흐름 검증 — 사용자가 하듯이 `bin/edge.sh`와 `bin/node.sh`를 각각 실행하고, edge console에서 메시지 2회와 command 명령을 직접 보내 결과가 edge 화면에 도착하는지 확인하는 기준 검증이다. +- 보조 E2E smoke — 임시 설정과 mock adapter로 최소 생존을 빠르게 확인하는 보조 검증이다. 이 결과만으로 완료 처리하지 않는다. - full-cycle 실제 구동 — 비효율적이어도 관련 사용자 명령과 실행 cycle을 한 번씩 실제 entrypoint로 통과시키는 검증이다. - 실제 외부 CLI 검증 — `claude`, `gemini`, `codex`, `opencode`처럼 외부 CLI 설치와 계정/환경이 필요한 기준 profile을 실제 호출하는 검증이다. ## 유지할 패턴 - 테스트는 테스트 파일 변경 여부가 아니라 작업 영향 범위로 결정한다. -- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 일반 Go 테스트, `bin/edge.sh` + `bin/node.sh` 기반 E2E smoke, full-cycle 실제 구동 흐름을 반드시 검증한다. +- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 일반 Go 테스트와 `bin/edge.sh` + `bin/node.sh` 기반 bin shell 사용자 흐름을 반드시 검증한다. 보조 E2E smoke는 추가로 수행할 수 있지만 완료 기준이 아니다. - 사용자 실행 파이프라인에는 `bin/**`, `apps/*/cmd/**`, `apps/*/internal/bootstrap/**`, edge-node transport/service/registry, adapter 실행/stream/cancel/status 경로, `configs/**`, `packages/config/**`, 관련 protobuf 계약 변경이 포함된다. -- 기본 E2E smoke는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 수행한다. -- E2E smoke에서는 최소한 node 등록, `/nodes` 확인, console 메시지 전송, delta/message 출력, complete event를 확인한다. +- bin shell 사용자 흐름 검증은 `bin/edge.sh`와 `bin/node.sh`를 별도 프로세스로 직접 실행하고, edge console prompt에 명령을 한 줄씩 입력한 뒤 기대 출력이 도착한 것을 확인하고 다음 입력으로 넘어간다. +- 메시지 검증 기준은 edge console에서 같은 session으로 메시지 2회를 보내고, 각 요청마다 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete`가 edge 화면에 표시되는 것이다. +- 완료 이벤트만으로 정상 판정하지 않는다. node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node-*-message]` 출력에 모두 표시되어야 하며, complete event는 모든 message payload가 edge에 도착한 뒤의 마감 신호로 본다. +- command 검증 기준은 edge console에서 `/nodes`와 변경 범위에 닿는 command를 직접 입력하고, node에서 온 결과가 edge 화면에 `[node-*-]` 또는 명확한 성공/unsupported/error 출력으로 표시되는 것이다. CLI 경로 변경 시 최소 `/capabilities`, `/transport`, `/sessions`, persistent profile이면 `/terminate-session`을 확인한다. +- 보조 E2E smoke는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 수행한다. +- 보조 E2E smoke에서는 최소한 node 등록, `/nodes` 확인, console 메시지 전송, delta/message 출력, complete event를 확인한다. - full-cycle 실제 구동에서는 startup/register, foreground run, session 변경, background run, terminate-session, status, 관련 routing/cancel/timeout/persistent session cycle을 실제 entrypoint로 한 번씩 통과시킨다. - 상세 수행 절차와 기능별 체크리스트는 `agent-ops/skills/project/e2e-smoke/SKILL.md`를 따른다. -- `make test-e2e` 같은 고정 명령이 생기면 그 명령을 우선 사용한다. 아직 고정 명령이 없으면 동일한 내용을 수동 smoke 절차로 검증하거나, 실행하지 못한 이유를 최종 보고에 명시한다. +- `make test-e2e` 또는 `scripts/e2e-smoke.sh`는 보조 smoke 명령이다. 실행할 수 있으면 보조 확인으로 기록하되, bin shell 사용자 흐름을 대체하지 않는다. - 풀테스트에서는 실제 외부 CLI profile 검증을 필수로 수행한다. 환경, 계정, provider, 원격 endpoint 문제로 호출할 수 없거나 실패한 profile은 누락하지 말고 profile별 실패 또는 blocker로 보고한다. -- 작업 최종 보고에는 실행한 테스트 명령, E2E smoke 수행 여부, full-cycle 실제 구동 수행 여부를 명시한다. 수행하지 못한 필수 검증은 이유와 남은 위험을 함께 적는다. +- 작업 최종 보고에는 실행한 테스트 명령, bin shell 사용자 흐름 수행 여부, 보조 E2E smoke 수행 여부, full-cycle 실제 구동 수행 여부를 명시한다. 수행하지 못한 필수 검증은 이유와 남은 위험을 함께 적는다. + +## 기준 출력 예시 + +아래처럼 edge console에서 입력한 메시지 2회와 command 결과가 edge 화면에 도착해야 기준을 통과한 것으로 본다. run id와 node alias는 실행 환경에 따라 달라질 수 있다. + +```text +edge> /nodes + test-node (test-node) + +edge> Convert token iop_manual_one and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node-test-node-event] start run_id=manual-... +[node-test-node-message] IOP_MANUAL_ONE_OK +[node-test-node-message] IOP_MANUAL_ONE_TAIL +[node-test-node-event] complete run_id=manual-... detail="idle-timeout" + +edge> Convert token iop_manual_two and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node-test-node-event] start run_id=manual-... +[node-test-node-message] IOP_MANUAL_TWO_OK +[node-test-node-message] IOP_MANUAL_TWO_TAIL +[node-test-node-event] complete run_id=manual-... detail="idle-timeout" + +edge> /capabilities +[node-test-node-capabilities] target=fake-cli session=default + adapter = cli + max_concurrency = 4 + targets = fake-cli + +edge> /transport +[node-test-node-transport] target=fake-cli session=default + adapter = cli + connected = true + node_id = test-node + session_id = default + target = fake-cli + +edge> /sessions +[node-test-node-sessions] target=fake-cli session=default + count = 1 + sessions = persistent:fake-cli/default + +edge> /terminate-session +terminated session default node=test-node +``` ## 다른 도메인과의 경계 @@ -46,7 +96,8 @@ ## 금지 사항 - 사용자 실행 파이프라인에 닿는 변경을 하고 유닛/패키지 테스트만으로 완료 처리하지 않는다. +- `make test-e2e`, `scripts/e2e-smoke.sh`, 또는 smoke 통과 출력만으로 완료 처리하지 않는다. - 관련 작업 후 full-cycle 실제 구동을 비용이 크다는 이유만으로 생략하지 않는다. -- 기본 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다. -- E2E smoke를 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. 임시 설정 파일이나 환경 변수 override를 사용한다. +- 보조 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다. +- 검증을 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. 임시 설정 파일이나 환경 변수 override를 사용한다. - 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다. diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md index 5c8c522..31b2217 100644 --- a/agent-ops/rules/project/rules.md +++ b/agent-ops/rules/project/rules.md @@ -76,5 +76,5 @@ ## 스킬 라우팅 -- 사용자 실행 파이프라인 검증, E2E smoke, full-cycle 실제 구동, `bin/edge.sh`/`bin/node.sh` 통합 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md` +- 사용자 실행 파이프라인 검증, bin shell 사용자 흐름, 메시지 2회 왕복, edge command 응답, 보조 E2E smoke, full-cycle 실제 구동, `bin/edge.sh`/`bin/node.sh` 통합 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md` - 반복 작업이 확인되면 `agent-ops/skills/project//SKILL.md`를 생성하고 이 표에 등록한다. diff --git a/agent-ops/skills/project/e2e-smoke/SKILL.md b/agent-ops/skills/project/e2e-smoke/SKILL.md index 8a8238a..e994d19 100644 --- a/agent-ops/skills/project/e2e-smoke/SKILL.md +++ b/agent-ops/skills/project/e2e-smoke/SKILL.md @@ -1,115 +1,143 @@ --- name: e2e-smoke -version: 1.0.0 -description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인 E2E smoke, full-cycle 실제 구동, 실제 외부 CLI 통합 검증 절차 +version: 1.1.0 +description: bin/edge.sh와 bin/node.sh 기반 bin shell 사용자 흐름 검증, 보조 E2E smoke, 실제 외부 CLI 통합 검증 절차 --- # e2e-smoke ## 목적 -사용자 실행 방식에 가까운 `bin/edge.sh` + `bin/node.sh` 경로로 edge-node 실행 파이프라인을 검증한다. 빠른 mock smoke로 최소 생존을 확인하고, 관련 작업 후에는 비효율적이어도 실제 구동 full-cycle과 실제 외부 CLI profile 호출을 한 번씩 통과시킨다. 실제 외부 CLI profile 검증은 풀테스트의 필수 항목이며, 불가능하거나 실패하면 완료로 숨기지 않고 profile별 blocker 또는 실패로 명시한다. +사용자가 실제로 테스트하는 방식에 맞춰 `bin/edge.sh`와 `bin/node.sh`를 각각 실행하고, edge console에서 메시지 2회와 command 명령을 직접 보내 결과가 edge 화면에 표시되는지 검증한다. `make test-e2e`와 `scripts/e2e-smoke.sh`는 최소 생존을 빠르게 확인하는 보조 smoke일 뿐이며, 사용자 실행 파이프라인 변경의 완료 기준이 아니다. ## 언제 호출할지 - 사용자 실행 파이프라인에 닿는 작업을 완료한 뒤 검증 단계에 들어갈 때 - `bin/**`, `apps/*/cmd/**`, `apps/*/internal/bootstrap/**`, edge-node transport/service/registry, adapter 실행/stream/cancel/status 경로를 변경했을 때 - `configs/**`, `packages/config/**`, 관련 protobuf 계약 변경이 edge-node 실행 방식에 영향을 줄 때 -- `make test-e2e` 또는 E2E smoke 스크립트를 만들거나 갱신할 때 +- `make test-e2e` 또는 보조 E2E smoke 스크립트를 만들거나 갱신할 때 ## 입력 - `scope`: 검증할 변경 범위와 영향을 받은 도메인 (필수) -- `mode`: `mock-smoke`, `full-cycle`, `real-cli`, `all` 중 하나 (선택, 기본값: `full-cycle`) +- `mode`: `bin-user-flow`, `mock-smoke`, `real-cli`, `full-cycle`, `all` 중 하나 (선택, 기본값: `bin-user-flow`) - `profiles`: 검증할 실제 외부 CLI profile 목록. 지정하지 않으면 기준 profile인 `claude`, `gemini`, `codex`, `opencode`를 대상으로 한다. (선택) - `config_strategy`: 임시 config 파일 또는 환경 변수 override 방식 (선택) ## 먼저 확인할 것 -- [ ] `agent-ops/rules/project/domain/testing/rules.md`를 읽고 이번 작업이 필수 E2E smoke 대상인지 확인한다. +- [ ] `agent-ops/rules/project/domain/testing/rules.md`를 읽고 이번 작업이 필수 bin shell 사용자 흐름 검증 대상인지 확인한다. - [ ] `bin/edge.sh`와 `bin/node.sh`가 현재 사용자 실행 entrypoint인지 확인한다. -- [ ] `make test-e2e` 같은 고정 명령이 이미 있으면 그 명령을 우선 사용한다. -- [ ] 고정 명령이 없으면 임시 config와 랜덤 포트로 수동 smoke 절차를 구성한다. -- [ ] 관련 작업 후에는 빠른 smoke만으로 완료 처리하지 말고 full-cycle 실제 구동 항목을 한 번씩 수행할 수 있는지 확인한다. -- [ ] 실제 외부 CLI command 경로, 로그인/계정 상태, provider 설정, workspace 권한이 준비되어 있는지 확인한다. +- [ ] `make test-e2e` 같은 고정 smoke 명령은 보조 확인으로만 취급한다. +- [ ] 기본 `configs/*.yaml`을 오염시키지 않도록 임시 config 또는 환경 변수 override 전략을 정한다. +- [ ] 실제 외부 CLI 검증이 필요한 경우 command 경로, 로그인/계정 상태, provider 설정, workspace 권한이 준비되어 있는지 확인한다. ## 실행 절차 1. **검증 범위 결정** - 변경 파일이 사용자 실행 파이프라인에 닿는지 확인한다. - - 필수 검증은 `go test ./...` 또는 대상 패키지 테스트, mock smoke, full-cycle 실제 구동이다. + - 필수 검증은 `go test ./...` 또는 대상 패키지 테스트와 bin shell 사용자 흐름 검증이다. + - 보조 smoke는 실행할 수 있으면 기록하되, 통과만으로 완료 처리하지 않는다. - 풀테스트에서는 실제 외부 CLI profile 검증을 full-cycle에 포함한다. - - real CLI 환경이 준비되지 않았거나 호출이 실패하면 완료로 숨기지 말고 profile별 blocker 또는 실패로 보고한다. -2. **기본 E2E smoke 준비** - - 기본 `configs/*.yaml`을 오염시키지 말고 임시 edge/node config 또는 환경 변수 override를 사용한다. - - edge listen 주소와 node edge 주소는 충돌을 피하기 위해 임시 포트를 사용한다. - - edge console target은 외부 CLI가 아닌 mock adapter target으로 구성한다. +2. **bin shell 사용자 흐름 준비** + - edge와 node는 `bin/edge.sh`, `bin/node.sh`를 각각 별도 프로세스로 실행한다. + - 임시 edge/node config 또는 환경 변수 override를 사용하고, edge listen 주소와 node edge 주소는 충돌을 피하기 위해 임시 포트를 사용한다. + - 검증용 target은 변경 범위에 맞춘다. 외부 CLI 자체가 검증 대상이 아니면 deterministic CLI profile을 사용해 메시지 출력 내용을 분명하게 만든다. -3. **기본 E2E smoke 실행** - - `bin/edge.sh`와 `bin/node.sh`를 실제 entrypoint로 실행한다. - - node가 edge에 register되고 console `/nodes` 출력에 node ID와 alias가 표시되는지 확인한다. - - mock adapter 대상으로 같은 session에서 메시지를 최소 2회 전송한다. - - 두 요청 모두 `[edge] sent`, `[node-*-event] start`, `[node-*-message]`, `[node-*-event] complete` 출력이 생기는지 확인한다. +3. **startup/register cycle** + - 먼저 `bin/edge.sh`를 실행해 edge console prompt가 뜨는지 확인한다. + - 다른 프로세스에서 `bin/node.sh`를 실행해 node가 edge에 연결되는지 확인한다. + - edge 화면에 `[node-*-event] connected reason="registered"`가 표시되어야 한다. + - edge console에서 `/nodes`를 입력하고 node ID와 alias가 표시되어야 한다. + +4. **메시지 2회 왕복 기준** + - edge console prompt에 메시지를 한 번 입력한다. + - 첫 번째 요청에서 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete`가 edge 화면에 도착해야 한다. + - node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node-*-message]` 출력에 모두 표시되었는지 비교한다. + - complete event만으로 정상 판정하지 않는다. complete event는 node가 생성한 모든 message payload가 edge에 표시된 뒤의 마감 신호여야 한다. + - 같은 session에서 두 번째 메시지를 입력하고 같은 출력 흐름이 다시 도착해야 한다. - 두 번째 메시지가 첫 번째 메시지 이후에도 같은 edge-node 연결에서 정상 처리되는지 확인한다. - - background off 기본값에서 foreground 응답 대기가 complete event까지 이어지는지 확인한다. - - `/session ` 변경 후 메시지를 보내고 출력의 session 값과 node event/message 흐름이 유지되는지 확인한다. - - `/terminate-session` 명령이 성공 출력 또는 mock adapter의 명확한 unsupported/error 출력을 내는지 확인한다. - - foreground mock smoke에서는 `` message line이 없어야 하고, foreground complete 출력 수가 전송한 run 수를 초과하지 않는지 확인한다. + - foreground run에서는 complete event 전에 다음 검증으로 넘어가지 않는다. -4. **full-cycle 실제 구동** - - smoke와 같은 임시 config/포트 방식으로 실제 `bin/edge.sh`와 `bin/node.sh`를 유지한 채 아래 사용자 명령 사이클을 한 번씩 통과시킨다. - - 명령을 한꺼번에 stdin에 밀어 넣지 말고, 각 cycle의 기대 출력이 로그에 나타난 뒤 다음 명령을 보낸다. 특히 background run은 async start/message/complete를 확인한 뒤 `/background off`나 `quit`로 넘어간다. - - startup/register cycle: edge 기동, node edge 대기, register, `/nodes`, node disconnect lifecycle 출력까지 확인한다. - - foreground run cycle: 같은 session에서 메시지를 최소 2회 전송하고 각 run의 sent/start/message/complete를 확인한다. - - session cycle: `/session `로 session을 바꾼 뒤 메시지를 보내고 출력의 session 값과 run 흐름을 확인한다. - - background cycle: `/background on` 후 메시지를 보내 async start/message/complete가 출력되는지 확인하고, `/background off`로 되돌린다. - - terminate cycle: `/terminate-session`을 실행하고 성공 출력 또는 명확한 unsupported/error 출력을 확인한다. - - status cycle: 현재 target에서 `/status`를 실행한다. 지원 profile이면 status 출력 내용을 확인하고, 미지원 profile이면 명확한 unsupported error를 확인한다. - - multi-node/routing을 바꾼 작업에서는 node 2대를 실제로 붙여 ambiguous error와 `/node ` 선택 후 라우팅을 한 번씩 확인한다. - - cancel/timeout/persistent session을 바꾼 작업에서는 해당 cycle도 실제 구동으로 한 번씩 확인한다. +5. **edge command 응답 기준** + - edge console에서 command를 직접 입력하고 결과가 edge 화면에 도착하는지 확인한다. + - 기본 command 기준은 `/nodes`, `/capabilities`, `/transport`, `/sessions`이다. + - persistent profile이면 `/terminate-session`을 실행하고 성공 출력 또는 명확한 unsupported/error 출력을 확인한다. + - status 경로를 바꾼 작업은 `/status` 결과를 확인한다. 지원 profile이면 status 출력 내용을 확인하고, 미지원 profile이면 명확한 unsupported error를 확인한다. + - multi-node/routing을 바꾼 작업은 node 2대를 실제로 붙여 ambiguous error와 `/node ` 선택 후 라우팅을 확인한다. + - cancel/timeout/persistent session을 바꾼 작업은 해당 cycle도 실제 구동으로 확인한다. -5. **edge console / transport 항목 확인** - - node 연결/해제 lifecycle event가 run event와 분리되어 출력되는지 확인한다. - - `RunEvent`의 `start`, `delta`, `complete`, `error`, `cancelled`가 console 출력 형식에 맞게 라우팅되는지 확인한다. - - node가 1대일 때는 node 선택 없이 single-node fallback으로 실행되는지 확인한다. - - node가 2대 이상인 smoke를 수행한다면 `/node ` 선택 없이는 ambiguous error가 출력되는지 확인한다. - - `/node ` 선택 후 선택된 node로 run request가 전달되고 출력 prefix가 해당 node label을 사용하는지 확인한다. +6. **기준 출력 예시** -6. **CLI profile 실제 구동 검증** +```text +edge> /nodes + test-node (test-node) + +edge> Convert token iop_manual_one and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node-test-node-event] start run_id=manual-... +[node-test-node-message] IOP_MANUAL_ONE_OK +[node-test-node-message] IOP_MANUAL_ONE_TAIL +[node-test-node-event] complete run_id=manual-... detail="idle-timeout" + +edge> Convert token iop_manual_two and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node-test-node-event] start run_id=manual-... +[node-test-node-message] IOP_MANUAL_TWO_OK +[node-test-node-message] IOP_MANUAL_TWO_TAIL +[node-test-node-event] complete run_id=manual-... detail="idle-timeout" + +edge> /capabilities +[node-test-node-capabilities] target=fake-cli session=default + adapter = cli + max_concurrency = 4 + targets = fake-cli + +edge> /transport +[node-test-node-transport] target=fake-cli session=default + adapter = cli + connected = true + node_id = test-node + session_id = default + target = fake-cli + +edge> /sessions +[node-test-node-sessions] target=fake-cli session=default + count = 1 + sessions = persistent:fake-cli/default + +edge> /terminate-session +terminated session default node=test-node +``` + +7. **보조 E2E smoke** + - `make test-e2e` 또는 `scripts/e2e-smoke.sh`는 보조 확인으로 실행할 수 있다. + - 보조 smoke에서는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 최소 생존을 확인한다. + - 보조 smoke 통과를 bin shell 사용자 흐름 통과로 대체해 보고하지 않는다. + +8. **CLI profile 실제 구동 검증** - 풀테스트에서는 기준 실제 외부 CLI profile을 한 번씩 실제 구동한다. - `claude`, `gemini`, `codex`, `opencode`를 대상으로 같은 session 메시지를 최소 2회 주고받는다. - 각 profile에서 start, delta-or-message, complete 출력이 모두 생기는지 확인한다. - - `opencode`는 로컬 모델 대표 profile로 보고 SSE 기반 delta/message와 complete 출력이 생기는지 확인한다. - command, 로그인, provider, workspace 권한, 원격 endpoint 문제로 실행할 수 없거나 실패한 profile은 누락이 아니라 실패/blocker로 profile별 보고한다. -7. **usage status 실제 구동 검증** - - `/status`로 `codex`, `claude`, `gemini` 사용량을 조회한다. - - `[edge] sent command=status`와 `[node-*-status]`가 출력되는지 확인한다. - - `codex`와 `claude`는 daily/weekly limit 또는 raw fallback 출력이 표시되는지 확인한다. - - `gemini`는 quota metadata 또는 model usage 출력이 표시되는지 확인한다. - - `opencode`처럼 status checker가 아직 없는 profile은 조용히 실패하지 않고 명확한 unsupported error를 출력하는지 확인한다. - - parsed limit이 있으면 label/reset 정보를 포함하고, parsed limit이 없으면 raw output 일부를 보여주는지 확인한다. - -8. **cancel / session lifecycle 검증** - - 변경 범위가 cancel, timeout, persistent session에 닿는 경우 수행한다. - - foreground run 중 cancel 요청이 전달되면 cancelled event가 출력되고 node store 상태가 cancelled로 마감되는지 확인한다. - - persistent CLI session에서 `/terminate-session` 후 같은 session 재사용 시 새 worker 생성 또는 `REQUIRE_EXISTING` 실패가 의도대로 동작하는지 확인한다. - - timeout 발생 시 timeout message와 error/cancelled 상태가 사용자 출력에 드러나는지 확인한다. - 9. **결과 보고** - 실행한 Go 테스트 명령과 결과를 적는다. - - 실행한 E2E smoke와 full-cycle 실제 구동 방식, 사용한 config 전략, 검증한 profile을 적는다. + - bin shell 사용자 흐름에서 사용한 config 전략, 메시지 2회 결과, command 응답 결과를 적는다. + - 보조 smoke를 실행했다면 별도 항목으로만 적는다. - full-cycle에서 돌린 cycle 목록과 돌리지 못한 cycle 목록을 구분해 적는다. - 필수 검증을 실행하지 못했다면 이유와 남은 위험을 명시한다. ## 실행 결과 검증 - [ ] 일반 Go 테스트 또는 대상 패키지 테스트 결과가 보고되었는가 -- [ ] 사용자 실행 파이프라인 변경 시 mock E2E smoke 수행 여부가 보고되었는가 -- [ ] 사용자 실행 파이프라인 변경 시 full-cycle 실제 구동 항목을 한 번씩 수행했는가 -- [ ] node register, `/nodes`, 메시지 최소 2회, event/message/complete 출력 확인 여부가 보고되었는가 -- [ ] session, background, terminate, status 등 관련 사용자 명령 cycle 수행 여부가 보고되었는가 +- [ ] `bin/edge.sh`와 `bin/node.sh`를 각각 실행한 bin shell 사용자 흐름 결과가 보고되었는가 +- [ ] node register와 `/nodes` 결과가 edge 화면에 표시되었는가 +- [ ] 같은 session에서 메시지 2회가 각각 sent/start/message/complete로 표시되었는가 +- [ ] node 로컬 `[node-message]` payload가 edge `[node-*-message]` 출력에 모두 표시되었는가 +- [ ] `/capabilities`, `/transport`, `/sessions`, 관련 command 결과가 edge 화면에 표시되었는가 +- [ ] 보조 smoke를 실행했다면 bin shell 사용자 흐름과 구분해 보고했는가 - [ ] 풀테스트에서 실제 외부 CLI profile별 메시지 왕복과 `/status` 결과가 보고되었는가 - 검증 실패 시: 실패한 항목, 관련 로그/출력 요약, 남은 위험을 최종 보고에 포함한다. @@ -117,10 +145,13 @@ description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인 ```text 검증 결과 -- Go 테스트: <명령> — <통과|실패|미실행 사유> -- E2E smoke: — <통과|실패|미실행 사유> +- Go 테스트: <명령> - <통과|실패|미실행 사유> +- Bin shell 사용자 흐름: <통과|실패|미실행 사유> +- 메시지 왕복: message x2 - <통과|실패|미실행 사유> +- Node message relay: node local payload == edge rendered payload - <통과|실패|미실행 사유> +- Edge command 응답: /nodes, /capabilities, /transport, /sessions, <관련 command> - <통과|실패|미실행 사유> +- 보조 E2E smoke: - <통과|실패|미실행 사유> - Full-cycle 실제 구동: <통과|실패|미실행 사유> -- 확인 항목: node register, /nodes, message x2, session, background, terminate, status, event/message/complete, <추가 항목> - Real CLI profile: - 남은 위험: <없음 또는 내용> ``` @@ -128,7 +159,8 @@ description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인 ## 금지 사항 - 사용자 실행 파이프라인에 닿는 변경을 하고 유닛/패키지 테스트만으로 완료 처리하지 않는다. -- 관련 작업 후 full-cycle 실제 구동을 smoke보다 비용이 크다는 이유만으로 생략하지 않는다. -- 기본 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다. -- E2E smoke를 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. +- `make test-e2e`, `scripts/e2e-smoke.sh`, 또는 smoke 통과 출력만으로 완료 처리하지 않는다. +- 관련 작업 후 bin shell 사용자 흐름을 비용이 크다는 이유만으로 생략하지 않는다. +- 보조 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다. +- 검증을 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. - 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다. diff --git a/apps/edge/README.md b/apps/edge/README.md index 2a7e15f..e693b40 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -23,10 +23,11 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m 실행 요청과 이벤트는 같은 TCP 연결을 공유하지만, 메시지 타입과 내부 파이프라인은 분리한다. `RunRequest`, `CancelRequest`, `NodeCommandRequest`는 edge가 node에 보내는 command/request 계열이고, `RunEvent`, `EdgeNodeEvent`는 node와 edge 내부에서 발생한 event 계열이다. -## 원격 CLI adapter E2E Smoke & 수동 테스트 +## 원격 CLI adapter bin shell 사용자 흐름 검증 -`make test-e2e` 명령은 `scripts/e2e-smoke.sh`를 실행하여 아래의 테스트 흐름을 임시 설정과 `mock` 프로필로 자동 검증한다. -실제 외부 프로필(예: `gemini`)을 테스트하려면 `IOP_E2E_PROFILE=gemini make test-e2e`와 같이 환경 변수를 지정한다. +사용자 실행 파이프라인 검증의 기준은 `make test-e2e`나 smoke 통과가 아니라, 사람이 하듯이 `bin/edge.sh`와 `bin/node.sh`를 각각 실행하고 edge console에서 메시지와 command를 직접 보내 결과가 edge 화면에 표시되는지 확인하는 것이다. +`make test-e2e` 명령은 `scripts/e2e-smoke.sh`를 실행하는 보조 smoke이며, 최소 생존 확인에는 쓸 수 있지만 완료 기준을 대체하지 않는다. +실제 외부 프로필(예: `gemini`)을 보조 smoke로 테스트하려면 `IOP_E2E_PROFILE=gemini make test-e2e`와 같이 환경 변수를 지정한다. 수동으로 실행하려면 아래 과정을 따른다. @@ -48,17 +49,53 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m 6. edge 콘솔에서 `/nodes`로 node 등록을 확인한다. 7. (선택 사항) node가 여러 대일 경우 `/node `로 대상을 선택한다. 1대일 경우 자동 선택된다. 8. edge 콘솔의 `edge>` 프롬프트에 메시지를 입력한다. -9. `[node-{alias}-event]` 라인으로 실행 상태를, `[node-{alias}-message]` 라인으로 adapter output을 확인한다. -10. edge 콘솔에서 `/exit` 또는 `quit`로 종료한다. +9. 각 메시지마다 `[edge] sent`, `[node{index}-evt] start`, 비어 있지 않은 `[node{index}-msg]`, `[node{index}-evt] complete`가 edge 화면에 표시되는지 확인한다. +10. node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node{index}-msg]` 출력에 모두 표시되었는지 확인한다. complete event만으로 정상 판정하지 않는다. +11. 같은 session에서 두 번째 메시지를 보내 같은 흐름이 다시 표시되는지 확인한다. +12. edge 콘솔에서 `/capabilities`, `/transport`, `/sessions` 등 command를 입력하고 `[node-{alias}-]` 결과가 edge 화면에 도착하는지 확인한다. persistent profile이면 `/terminate-session`도 확인한다. +13. edge 콘솔에서 `/exit` 또는 `quit`로 종료한다. -예상 출력: +기준 출력 예시: ```text -edge> hello -[edge] sent run_id=manual-... node=local-node adapter=cli target=opencode session=default background=false -[node-local-node-event] start run_id=manual-... -[node-local-node-event] complete run_id=manual-... detail="opencode sse execution complete" -[node-local-node-message] +edge> /nodes + test-node (test-node) + +edge> Convert token iop_manual_one and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node0-evt] start run_id=manual-... +[node0-msg] IOP_MANUAL_ONE_OK +[node0-msg] IOP_MANUAL_ONE_TAIL +[node0-evt] complete run_id=manual-... detail="idle-timeout" + +edge> Convert token iop_manual_two and reply only with converted token +[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false +[node0-evt] start run_id=manual-... +[node0-msg] IOP_MANUAL_TWO_OK +[node0-msg] IOP_MANUAL_TWO_TAIL +[node0-evt] complete run_id=manual-... detail="idle-timeout" + +edge> /capabilities +[node0-capabilities] target=fake-cli session=default + adapter = cli + max_concurrency = 4 + targets = fake-cli + +edge> /transport +[node0-transport] target=fake-cli session=default + adapter = cli + connected = true + node_id = test-node + session_id = default + target = fake-cli + +edge> /sessions +[node0-sessions] target=fake-cli session=default + count = 1 + sessions = persistent:fake-cli/default + +edge> /terminate-session +terminated session default node=test-node ``` ## Console 명령 diff --git a/apps/edge/internal/node/registry.go b/apps/edge/internal/node/registry.go index 11dc66b..c6319dd 100644 --- a/apps/edge/internal/node/registry.go +++ b/apps/edge/internal/node/registry.go @@ -9,34 +9,68 @@ import ( // NodeEntry represents one connected node. type NodeEntry struct { - NodeID string - Alias string - Client *toki.TcpClient + NodeID string + Alias string + Client *toki.TcpClient + Index int + HasIndex bool } // Registry manages all nodes connected to edge. type Registry struct { - mu sync.RWMutex - byID map[string]*NodeEntry - byAlias map[string]*NodeEntry + mu sync.RWMutex + byID map[string]*NodeEntry + byAlias map[string]*NodeEntry + indexByID map[string]int + nextIndex int } func NewRegistry() *Registry { return &Registry{ - byID: make(map[string]*NodeEntry), - byAlias: make(map[string]*NodeEntry), + byID: make(map[string]*NodeEntry), + byAlias: make(map[string]*NodeEntry), + indexByID: make(map[string]int), } } func (r *Registry) Register(entry *NodeEntry) { r.mu.Lock() defer r.mu.Unlock() + if idx, ok := r.indexByID[entry.NodeID]; ok { + entry.Index = idx + entry.HasIndex = true + } else { + if !entry.HasIndex { + entry.Index = r.nextIndex + } + entry.HasIndex = true + r.indexByID[entry.NodeID] = entry.Index + if entry.Index >= r.nextIndex { + r.nextIndex = entry.Index + 1 + } + } r.byID[entry.NodeID] = entry if entry.Alias != "" { r.byAlias[entry.Alias] = entry } } +func (e *NodeEntry) DisplayLabel() string { + if e == nil { + return "unknown" + } + if e.HasIndex { + return fmt.Sprintf("node%d", e.Index) + } + if e.Alias != "" { + return e.Alias + } + if e.NodeID != "" { + return e.NodeID + } + return "unknown" +} + func (r *Registry) Unregister(nodeID string) { r.mu.Lock() defer r.mu.Unlock() diff --git a/apps/edge/internal/node/store.go b/apps/edge/internal/node/store.go index fc5dbd0..7363d52 100644 --- a/apps/edge/internal/node/store.go +++ b/apps/edge/internal/node/store.go @@ -13,6 +13,7 @@ type NodeRecord struct { ID string Alias string Token string + Index int Adapters config.AdaptersConf Runtime config.RuntimeConf } @@ -93,6 +94,7 @@ func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) { ID: nodeID, Alias: d.Alias, Token: d.Token, + Index: i, Adapters: d.Adapters, Runtime: d.Runtime, }) diff --git a/apps/edge/internal/opsconsole/console.go b/apps/edge/internal/opsconsole/console.go index 65bd5f8..762f998 100644 --- a/apps/edge/internal/opsconsole/console.go +++ b/apps/edge/internal/opsconsole/console.go @@ -274,28 +274,28 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou } label := events.NodeLabel(event) if response == nil { - response = NewResponseStream(out, fmt.Sprintf("[node-%s-message] ", label)) + response = NewResponseStream(out, nodeMessagePrefix(label)) } switch event.GetType() { case "start": - fmt.Fprintf(out, "[node-%s-event] start run_id=%s\n", label, handle.RunID) + fmt.Fprintf(out, "%s start run_id=%s\n", nodeEventPrefix(label), handle.RunID) case "delta": response.Write(event.GetDelta()) case "complete": response.Finish() - fmt.Fprintf(out, "[node-%s-event] complete run_id=%s detail=%q\n", label, handle.RunID, event.GetMessage()) + fmt.Fprintf(out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetMessage()) return nil case "cancelled": response.FinishIfStarted() - fmt.Fprintf(out, "[node-%s-event] cancelled run_id=%s\n", label, handle.RunID) + fmt.Fprintf(out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), handle.RunID) return nil case "error": response.FinishIfStarted() - fmt.Fprintf(out, "[node-%s-event] error run_id=%s detail=%q\n", label, handle.RunID, event.GetError()) + fmt.Fprintf(out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetError()) return fmt.Errorf("node reported error") default: - fmt.Fprintf(out, "[node-%s-event] %s run_id=%s detail=%q\n", label, event.GetType(), handle.RunID, event.GetMessage()) + fmt.Fprintf(out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), handle.RunID, event.GetMessage()) } } } diff --git a/apps/edge/internal/opsconsole/console_test.go b/apps/edge/internal/opsconsole/console_test.go index 7bf9178..07e21b9 100644 --- a/apps/edge/internal/opsconsole/console_test.go +++ b/apps/edge/internal/opsconsole/console_test.go @@ -170,7 +170,7 @@ func TestSendRun_SubmitRunRequest_MetadataSource(t *testing.T) { func TestFormatNodeCommandView_RendersSortedResult(t *testing.T) { view := edgeservice.NodeCommandView{ - NodeLabel: "alpha", + NodeLabel: "node0", Target: "codex", SessionID: "default", Result: map[string]string{ @@ -181,7 +181,7 @@ func TestFormatNodeCommandView_RendersSortedResult(t *testing.T) { var out bytes.Buffer FormatNodeCommandView(&out, "capabilities", view) got := out.String() - if !strings.Contains(got, "[node-alpha-capabilities] target=codex session=default") { + if !strings.Contains(got, "[node0-capabilities] target=codex session=default") { t.Errorf("missing header: %q", got) } // keys must appear in sorted order: targets before version diff --git a/apps/edge/internal/opsconsole/events.go b/apps/edge/internal/opsconsole/events.go index 2dc9446..8a5c19a 100644 --- a/apps/edge/internal/opsconsole/events.go +++ b/apps/edge/internal/opsconsole/events.go @@ -95,35 +95,45 @@ func (r *EventRouter) Drain(ctx context.Context, runEvents <-chan *iop.RunEvent, } func (r *EventRouter) NodeLabel(event *iop.RunEvent) string { + nodeID := event.GetNodeId() + if r.registry != nil { + if entry, ok := r.registry.Get(nodeID); ok { + return entry.DisplayLabel() + } + if alias := event.GetNodeAlias(); alias != "" { + if entry, err := r.registry.Resolve(alias); err == nil { + return entry.DisplayLabel() + } + } + } if alias := event.GetNodeAlias(); alias != "" { return alias } - nodeID := event.GetNodeId() - if nodeID == "" { - return "unknown" + if nodeID != "" { + return nodeID } - if r.registry != nil { - if entry, ok := r.registry.Get(nodeID); ok && entry.Alias != "" { - return entry.Alias - } - } - return nodeID + return "unknown" } func (r *EventRouter) edgeNodeLabel(event *iop.EdgeNodeEvent) string { - if event.GetAlias() != "" { - return event.GetAlias() - } nodeID := event.GetNodeId() - if nodeID == "" { - return "unknown" - } if r.registry != nil { - if entry, ok := r.registry.Get(nodeID); ok && entry.Alias != "" { - return entry.Alias + if entry, ok := r.registry.Get(nodeID); ok { + return entry.DisplayLabel() + } + if alias := event.GetAlias(); alias != "" { + if entry, err := r.registry.Resolve(alias); err == nil { + return entry.DisplayLabel() + } } } - return nodeID + if alias := event.GetAlias(); alias != "" { + return alias + } + if nodeID != "" { + return nodeID + } + return "unknown" } func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) { @@ -135,11 +145,11 @@ func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) { detail := transportCloseDetail(event.GetMetadata()) switch event.GetType() { case eventpkg.TypeNodeConnected: - fmt.Fprintf(r.out, "[node-%s-event] connected reason=%q%s\n", label, event.GetReason(), detail) + fmt.Fprintf(r.out, "%s connected reason=%q%s\n", nodeEventPrefix(label), event.GetReason(), detail) case eventpkg.TypeNodeDisconnected: - fmt.Fprintf(r.out, "[node-%s-event] disconnected reason=%q%s\n", label, event.GetReason(), detail) + fmt.Fprintf(r.out, "%s disconnected reason=%q%s\n", nodeEventPrefix(label), event.GetReason(), detail) default: - fmt.Fprintf(r.out, "[node-%s-event] %s reason=%q%s\n", label, event.GetType(), event.GetReason(), detail) + fmt.Fprintf(r.out, "%s %s reason=%q%s\n", nodeEventPrefix(label), event.GetType(), event.GetReason(), detail) } } @@ -166,20 +176,20 @@ func (r *EventRouter) printAsyncLocked(event *iop.RunEvent) { label := r.NodeLabel(event) switch event.GetType() { case "start": - fmt.Fprintf(r.out, "[node-%s-event] start run_id=%s session=%s background=%v\n", label, runID, event.GetSessionId(), event.GetBackground()) + fmt.Fprintf(r.out, "%s start run_id=%s session=%s background=%v\n", nodeEventPrefix(label), runID, event.GetSessionId(), event.GetBackground()) case "delta": r.responseStream(runID, label).Write(event.GetDelta()) case "complete": r.finishStream(runID, label) - fmt.Fprintf(r.out, "[node-%s-event] complete run_id=%s detail=%q\n", label, runID, event.GetMessage()) + fmt.Fprintf(r.out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), runID, event.GetMessage()) case "cancelled": r.finishStreamIfStarted(runID) - fmt.Fprintf(r.out, "[node-%s-event] cancelled run_id=%s\n", label, runID) + fmt.Fprintf(r.out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), runID) case "error": r.finishStreamIfStarted(runID) - fmt.Fprintf(r.out, "[node-%s-event] error run_id=%s detail=%q\n", label, runID, event.GetError()) + fmt.Fprintf(r.out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), runID, event.GetError()) default: - fmt.Fprintf(r.out, "[node-%s-event] %s run_id=%s detail=%q\n", label, event.GetType(), runID, event.GetMessage()) + fmt.Fprintf(r.out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), runID, event.GetMessage()) } } @@ -187,7 +197,7 @@ func (r *EventRouter) responseStream(runID, label string) *ResponseStream { if s, ok := r.streams[runID]; ok { return s } - s := NewResponseStream(r.out, fmt.Sprintf("[node-%s-message] ", label)) + s := NewResponseStream(r.out, nodeMessagePrefix(label)) r.streams[runID] = s return s } @@ -198,7 +208,7 @@ func (r *EventRouter) finishStream(runID, label string) { delete(r.streams, runID) return } - NewResponseStream(r.out, fmt.Sprintf("[node-%s-message] ", label)).Finish() + NewResponseStream(r.out, nodeMessagePrefix(label)).Finish() } func (r *EventRouter) finishStreamIfStarted(runID string) { @@ -219,6 +229,14 @@ func NewResponseStream(out io.Writer, prefix string) *ResponseStream { return &ResponseStream{out: out, prefix: prefix, endedWithNewline: true} } +func nodeEventPrefix(label string) string { + return fmt.Sprintf("[%s-evt]", label) +} + +func nodeMessagePrefix(label string) string { + return fmt.Sprintf("[%s-msg] ", label) +} + func (s *ResponseStream) Write(delta string) { if s.out == nil || delta == "" { return diff --git a/apps/edge/internal/opsconsole/events_test.go b/apps/edge/internal/opsconsole/events_test.go index 13b197f..d163efa 100644 --- a/apps/edge/internal/opsconsole/events_test.go +++ b/apps/edge/internal/opsconsole/events_test.go @@ -45,7 +45,7 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) { router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "start", SessionId: "s1", Background: true}) router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: "hello", Background: true}) - if got := out.String(); !strings.Contains(got, "[node-unknown-message] hello") { + if got := out.String(); !strings.Contains(got, "[unknown-msg] hello") { t.Fatalf("expected delta to be printed before completion, got:\n%s", got) } router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: " world\n", Background: true}) @@ -53,9 +53,9 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) { got := out.String() for _, want := range []string{ - "[node-unknown-event] start run_id=run-bg session=s1 background=true", - "[node-unknown-event] complete run_id=run-bg detail=\"done\"", - "[node-unknown-message] hello world", + "[unknown-evt] start run_id=run-bg session=s1 background=true", + "[unknown-evt] complete run_id=run-bg detail=\"done\"", + "[unknown-msg] hello world", } { if !strings.Contains(got, want) { t.Fatalf("expected output to contain %q, got:\n%s", want, got) @@ -65,28 +65,28 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) { func TestResponseStreamWritesBeforeFinish(t *testing.T) { var out bytes.Buffer - stream := NewResponseStream(&out, "[node-test-message] ") + stream := NewResponseStream(&out, "[node0-msg] ") stream.Write("hello") - if got := out.String(); got != "[node-test-message] hello" { + if got := out.String(); got != "[node0-msg] hello" { t.Fatalf("Write should print immediately, got %q", got) } stream.Write(" world") stream.Finish() - if got := out.String(); got != "[node-test-message] hello world\n" { + if got := out.String(); got != "[node0-msg] hello world\n" { t.Fatalf("Finish should close the line, got %q", got) } } func TestResponseStreamPrefixesEachMessageLine(t *testing.T) { var out bytes.Buffer - stream := NewResponseStream(&out, "[node-test-message] ") + stream := NewResponseStream(&out, "[node0-msg] ") stream.Write("line one\nline two\nline three") stream.Finish() - want := "[node-test-message] line one\n[node-test-message] line two\n[node-test-message] line three\n" + want := "[node0-msg] line one\n[node0-msg] line two\n[node0-msg] line three\n" if got := out.String(); got != want { t.Fatalf("unexpected multiline output:\nwant: %q\n got: %q", want, got) } @@ -106,8 +106,8 @@ func TestEventRouterPrintsNodeScopedAsyncRun(t *testing.T) { }) got := out.String() - if !strings.Contains(got, "[node-alias-1-event] start run_id=run-1") { - t.Errorf("expected node-alias-1-event label, got:\n%s", got) + if !strings.Contains(got, "[node0-evt] start run_id=run-1") { + t.Errorf("expected node0 event label, got:\n%s", got) } } @@ -126,8 +126,8 @@ func TestEventRouterPrefersRunEventAlias(t *testing.T) { }) got := out.String() - if !strings.Contains(got, "[node-event-alias-event] start run_id=run-1") { - t.Errorf("expected event-alias label, got:\n%s", got) + if !strings.Contains(got, "[node0-evt] start run_id=run-1") { + t.Errorf("expected node0 event label, got:\n%s", got) } } @@ -143,14 +143,16 @@ func TestEventRouterFallsBackToNodeID(t *testing.T) { }) got := out.String() - if !strings.Contains(got, "[node-some-raw-id-event] start run_id=run-1") { - t.Errorf("expected node-some-raw-id-event label, got:\n%s", got) + if !strings.Contains(got, "[some-raw-id-evt] start run_id=run-1") { + t.Errorf("expected raw id event label, got:\n%s", got) } } func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) { var out bytes.Buffer - router := NewEventRouter(&out, nil, nil) + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"}) + router := NewEventRouter(&out, reg, nil) router.HandleNodeEvent(&iop.EdgeNodeEvent{ Type: eventpkg.TypeNodeDisconnected, @@ -164,7 +166,7 @@ func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) { }) got := out.String() - if !strings.Contains(got, `[node-alias-1-event] disconnected reason="transport_closed"`) { + if !strings.Contains(got, `[node0-evt] disconnected reason="transport_closed"`) { t.Errorf("expected disconnected lifecycle output, got:\n%s", got) } if !strings.Contains(got, `transport_close_reason="heartbeat_timeout"`) { diff --git a/apps/edge/internal/opsconsole/status.go b/apps/edge/internal/opsconsole/status.go index caee840..2c31c0f 100644 --- a/apps/edge/internal/opsconsole/status.go +++ b/apps/edge/internal/opsconsole/status.go @@ -86,7 +86,7 @@ func sendNodeCommandView(ctx context.Context, out io.Writer, target *TargetState // FormatNodeCommandView renders a node command result map in a stable, // sorted order so output is predictable for tests and humans. func FormatNodeCommandView(out io.Writer, label string, view edgeservice.NodeCommandView) { - fmt.Fprintf(out, "[node-%s-%s] target=%s session=%s\n", view.NodeLabel, label, view.Target, view.SessionID) + fmt.Fprintf(out, "[%s-%s] target=%s session=%s\n", view.NodeLabel, label, view.Target, view.SessionID) if len(view.Result) == 0 { fmt.Fprintf(out, "no %s payload returned\n", label) return @@ -102,7 +102,7 @@ func FormatNodeCommandView(out io.Writer, label string, view edgeservice.NodeCom } func FormatUsageStatus(out io.Writer, nodeAlias, targetName, sessionID string, status *iop.AgentUsageStatus) { - fmt.Fprintf(out, "[node-%s-status] target=%s session=%s\n", nodeAlias, targetName, sessionID) + fmt.Fprintf(out, "[%s-status] target=%s session=%s\n", nodeAlias, targetName, sessionID) if status == nil { fmt.Fprintln(out, "no usage status provided") diff --git a/apps/edge/internal/service/service.go b/apps/edge/internal/service/service.go index 7b3d837..6675994 100644 --- a/apps/edge/internal/service/service.go +++ b/apps/edge/internal/service/service.go @@ -423,8 +423,5 @@ func normalizeTimeoutSec(timeoutSec int) int { } func nodeLabel(entry *edgenode.NodeEntry) string { - if entry.Alias != "" { - return entry.Alias - } - return entry.NodeID + return entry.DisplayLabel() } diff --git a/apps/edge/internal/service/service_test.go b/apps/edge/internal/service/service_test.go index ed73ca1..3fb7104 100644 --- a/apps/edge/internal/service/service_test.go +++ b/apps/edge/internal/service/service_test.go @@ -161,11 +161,11 @@ func TestListNodesReturnsSnapshots(t *testing.T) { for _, s := range snaps { byID[s.NodeID] = s } - if got := byID["node-1"]; got.Alias != "alpha" || got.Label != "alpha" { - t.Errorf("node-1 snapshot: %+v, want Label=alpha", got) + if got := byID["node-1"]; got.Alias != "alpha" || got.Label != "node0" { + t.Errorf("node-1 snapshot: %+v, want Label=node0", got) } - if got := byID["node-2"]; got.Alias != "" || got.Label != "node-2" { - t.Errorf("node-2 snapshot: %+v, want Label=node-2", got) + if got := byID["node-2"]; got.Alias != "" || got.Label != "node1" { + t.Errorf("node-2 snapshot: %+v, want Label=node1", got) } } @@ -178,7 +178,7 @@ func TestResolveNodeSnapshotReturnsDTO(t *testing.T) { if err != nil { t.Fatalf("ResolveNodeSnapshot: %v", err) } - if snap.NodeID != "node-1" || snap.Label != "alpha" { + if snap.NodeID != "node-1" || snap.Label != "node0" { t.Errorf("snapshot: %+v", snap) } } diff --git a/apps/edge/internal/transport/server.go b/apps/edge/internal/transport/server.go index dce4702..0ad9df5 100644 --- a/apps/edge/internal/transport/server.go +++ b/apps/edge/internal/transport/server.go @@ -154,9 +154,11 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { } entry := &edgenode.NodeEntry{ - NodeID: rec.ID, - Alias: rec.Alias, - Client: client, + NodeID: rec.ID, + Alias: rec.Alias, + Client: client, + Index: rec.Index, + HasIndex: true, } toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) { if e.NodeId == "" { @@ -168,7 +170,6 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { s.emitNodeEvent(e) }) client.AddDisconnectListener(func(_ *toki.TcpClient) { - s.registry.Unregister(rec.ID) transportInfo := client.DisconnectInfo() fields := []zap.Field{zap.String("node_id", rec.ID)} fields = append(fields, transportDisconnectFields(transportInfo)...) @@ -185,6 +186,7 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) { reason, transportDisconnectMetadata(transportInfo), )) + s.registry.Unregister(rec.ID) }) s.registry.Register(entry) s.logger.Info("node registered", diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index 490b86e..6f2dd57 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -184,14 +184,18 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, Delta: delta, Timestamp: time.Now(), }) - } else if waitForFilteredMessage { + } else if waitForFilteredMessage && !outputFilter.HasOutput() { idleTimer.Reset(idleTimeout) continue } + completeMessage := "idle-timeout" + if msg := outputFilter.CompletionMessage(); msg != "" { + completeMessage = msg + } return sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeComplete, - Message: "idle-timeout", + Message: completeMessage, Usage: &runtime.UsageStats{ InputTokens: len(strings.Fields(prompt)), OutputTokens: outputTokens, diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index 36e7be1..321823f 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -464,10 +464,61 @@ func TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome(t *testing.T) { t.Fatalf("execute: %v", err) } - combined := testutil.CollectDeltas(sink.Events()) - if combined != "message:hello\n" { + events := sink.Events() + combined := testutil.CollectDeltas(events) + if combined != "message:hello" { t.Fatalf("expected only assistant message delta, got %q", combined) } + if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" { + t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message) + } +} + +func TestCLIExecutePersistentClaudeTUIStreamsLineBeforeIdle(t *testing.T) { + testutil.RequirePTYSupport(t) + + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "claude-tui": { + Command: "sh", + Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf 'sud fragment\r\n\342\234\242 Dilly-dallying\342\200\246\r\n* Dilly-dallying\342\200\246\r\n'; sleep 0.05; printf '\342\227\217 sudo runs commands\r\n\342\235\257 '; sleep 0.15; printf '\r\033[2A\033[2K\342\227\217 sudo runs commands with elevated privileges.\r\n\342\235\257 '; done`}, + Persistent: true, + Terminal: true, + ResponseIdleTimeoutMS: 300, + StartupIdleTimeoutMS: 50, + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + + ctx := context.Background() + if err := c.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + defer func() { _ = c.Stop(ctx) }() + + runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + sink := &testutil.FakeSink{} + err := c.Execute(runCtx, noderuntime.ExecutionSpec{ + RunID: "run-growing", + Target: "claude-tui", + Input: map[string]any{"prompt": "explain sudo"}, + }, sink) + if err != nil { + t.Fatalf("execute: %v", err) + } + + events := sink.Events() + combined := testutil.CollectDeltas(events) + if combined != "sudo runs commands with elevated privileges." { + t.Fatalf("expected full growing message, got %q", combined) + } + if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" { + t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message) + } } func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) { diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go index 7156d10..de083d0 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -13,6 +13,8 @@ import ( type persistentOutputFilter interface { Filter(chunk string) string Flush() string + HasOutput() bool + CompletionMessage() string } type passthroughOutputFilter struct{} @@ -25,6 +27,14 @@ func (passthroughOutputFilter) Flush() string { return "" } +func (passthroughOutputFilter) HasOutput() bool { + return false +} + +func (passthroughOutputFilter) CompletionMessage() string { + return "" +} + func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string) persistentOutputFilter { if profile.Terminal && isClaudeTerminalProfile(target, profile) { return newClaudeTUIOutputFilter(prompt) @@ -40,8 +50,15 @@ func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool } type claudeTUIOutputFilter struct { - raw strings.Builder - prompt string + raw strings.Builder + prompt string + emitted string + seenAssistant bool +} + +type claudeMessageSnapshot struct { + text string + closed bool } func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter { @@ -50,25 +67,106 @@ func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter { func (f *claudeTUIOutputFilter) Filter(chunk string) string { appendBounded(&f.raw, chunk, 256*1024) - return "" + message := f.currentMessage() + return f.emit(message.text, message.closed, false) } func (f *claudeTUIOutputFilter) Flush() string { - screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols) - message := latestClaudeAssistantMessage(screen) - if message == "" { - message = fallbackClaudeVisibleMessage(screen, f.prompt) - } - if message == "" { - message = fallbackClaudeVisibleMessage(cleanClaudeTerminalOutput(f.raw.String()), f.prompt) - } - if message == "" { - return "" - } - return message + "\n" + message := f.currentMessage() + return f.emit(message.text, true, true) } -func latestClaudeAssistantMessage(screen string) string { +func (f *claudeTUIOutputFilter) HasOutput() bool { + return f.emitted != "" || f.currentMessage().text != "" +} + +func (f *claudeTUIOutputFilter) CompletionMessage() string { + if f.emitted == "" { + return "" + } + if claudeScreenHasInputPrompt(status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)) { + return "prompt-ready" + } + return "" +} + +func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { + screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols) + clean := cleanClaudeTerminalOutput(f.raw.String()) + message, closed := latestClaudeAssistantMessage(screen) + if cleanMessage, cleanClosed := latestClaudeAssistantMessageFromCleanOutput(clean); cleanMessage != "" { + if message == "" { + f.seenAssistant = true + return claudeMessageSnapshot{text: cleanMessage, closed: cleanClosed} + } + if cleanMessage == message { + closed = closed || cleanClosed + } + } + if message != "" { + f.seenAssistant = true + } + if !f.seenAssistant { + return claudeMessageSnapshot{} + } + if message == "" { + message = fallbackClaudeVisibleMessage(screen, f.prompt) + if message != "" { + closed = claudeScreenHasInputPrompt(screen) || strings.HasSuffix(clean, "\n") + } + } + if message == "" { + message = fallbackClaudeVisibleMessage(clean, f.prompt) + if message != "" { + closed = strings.HasSuffix(clean, "\n") + } + } + return claudeMessageSnapshot{text: message, closed: closed} +} + +func (f *claudeTUIOutputFilter) emit(message string, closed bool, flush bool) string { + message = strings.TrimRight(message, "\n") + if !flush { + message = readyLinePrefix(message, closed) + } + if message == "" || message == f.emitted { + return "" + } + if f.emitted == "" { + f.emitted = message + return message + } + if strings.HasPrefix(message, f.emitted) { + delta := strings.TrimPrefix(message, f.emitted) + f.emitted = message + return delta + } + f.emitted = message + return "\n" + message +} + +func readyLinePrefix(message string, closed bool) string { + if closed { + return message + } + end := strings.LastIndex(message, "\n") + if end < 0 { + return "" + } + return message[:end+1] +} + +func claudeScreenHasInputPrompt(screen string) bool { + for _, line := range strings.Split(screen, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "\u276f") { + return true + } + } + return false +} + +func latestClaudeAssistantMessage(screen string) (string, bool) { lines := strings.Split(screen, "\n") start := -1 for i, line := range lines { @@ -77,10 +175,11 @@ func latestClaudeAssistantMessage(screen string) string { } } if start < 0 { - return "" + return "", false } out := make([]string, 0, len(lines)-start) + closed := false if text, ok := claudeAssistantLine(lines[start]); ok { appendClaudeMessageLine(&out, text) } @@ -93,11 +192,51 @@ func latestClaudeAssistantMessage(screen string) string { continue } if isClaudeTUIBoundaryLine(line) { + closed = true break } appendClaudeMessageLine(&out, line) } - return strings.Join(trimTrailingEmptyLines(out), "\n") + return strings.Join(trimTrailingEmptyLines(out), "\n"), closed +} + +func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) { + lines := strings.SplitAfter(text, "\n") + start := -1 + for i, line := range lines { + if _, ok := claudeAssistantLine(strings.TrimRight(line, "\r\n")); ok { + start = i + } + } + if start < 0 { + return "", false + } + + out := make([]string, 0, len(lines)-start) + closed := false + if text, ok := claudeAssistantLine(strings.TrimRight(lines[start], "\r\n")); ok { + appendClaudeMessageLine(&out, text) + closed = strings.HasSuffix(lines[start], "\n") + } + for _, raw := range lines[start+1:] { + line := strings.TrimSpace(strings.TrimRight(raw, "\r\n")) + if line == "" { + if len(out) > 0 { + out = append(out, "") + if strings.HasSuffix(raw, "\n") { + closed = true + } + } + continue + } + if isClaudeTUIBoundaryLine(line) { + closed = true + break + } + appendClaudeMessageLine(&out, line) + closed = strings.HasSuffix(raw, "\n") + } + return strings.Join(trimTrailingEmptyLines(out), "\n"), closed } func appendClaudeMessageLine(out *[]string, line string) { @@ -132,7 +271,7 @@ func isClaudeTUIBoundaryLine(line string) bool { return true case strings.HasPrefix(line, "\u2570") || strings.HasPrefix(line, "\u256d"): return true - case strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u00b7"): + case strings.HasPrefix(line, "\u2722") || strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u273d") || strings.HasPrefix(line, "\u00b7"): return true case strings.HasPrefix(line, "\u23bf"): return true @@ -235,6 +374,8 @@ func isClaudeTransientStatusLine(line string) bool { for _, word := range []string{ "cogitating", "crunched", + "dallying", + "dilly", "pondering", "processing", "thinking", diff --git a/apps/node/internal/adapters/cli/persistent_output_filter_test.go b/apps/node/internal/adapters/cli/persistent_output_filter_test.go index 0641c2a..5520903 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter_test.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -10,13 +10,12 @@ import ( func TestClaudeTUIOutputFilterExtractsAssistantMessageOnly(t *testing.T) { filter := newClaudeTUIOutputFilter("") - if got := filter.Filter("\x1b[2C\r\x1b[7A\u25cf\x1b[1CIOP_MESSAGE_ONLY\r\x1b[2B\u00b7 Cogitating... (2s · 1 tokens)\x1b[K\r\n\u276f "); got != "" { - t.Fatalf("Filter() = %q, want deferred output", got) + got := filter.Filter("\x1b[2C\r\x1b[7A\u25cf\x1b[1CIOP_MESSAGE_ONLY.\r\x1b[2B\u00b7 Cogitating... (2s · 1 tokens)\x1b[K\r\n\u276f ") + if got != "IOP_MESSAGE_ONLY." { + t.Fatalf("Filter() = %q, want %q", got, "IOP_MESSAGE_ONLY.") } - got := filter.Flush() - - if got != "IOP_MESSAGE_ONLY\n" { - t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONLY\n") + if got := filter.Flush(); got != "" { + t.Fatalf("Flush() should not duplicate already-emitted output, got %q", got) } if strings.Contains(got, "\x1b") || strings.Contains(got, "Cogitating") || strings.Contains(got, "\u276f") { t.Fatalf("filtered message still contains terminal chrome: %q", got) @@ -26,25 +25,25 @@ func TestClaudeTUIOutputFilterExtractsAssistantMessageOnly(t *testing.T) { func TestClaudeTUIOutputFilterSuppressesRepaintedMessage(t *testing.T) { filter := newClaudeTUIOutputFilter("") - if got := filter.Filter("\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" { - t.Fatalf("Filter() = %q, want deferred output", got) + if got := filter.Filter("\u25cf IOP_MESSAGE_ONCE.\r\n"); got != "IOP_MESSAGE_ONCE." { + t.Fatalf("Filter() = %q, want first message", got) } - if got := filter.Filter("\r\x1b[4A\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" { + if got := filter.Filter("\r\x1b[4A\u25cf IOP_MESSAGE_ONCE.\r\n"); got != "" { t.Fatalf("repainted Filter() = %q, want deferred output", got) } - if got := filter.Flush(); got != "IOP_MESSAGE_ONCE\n" { - t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONCE\n") + if got := filter.Flush(); got != "" { + t.Fatalf("Flush() should not duplicate already-emitted output, got %q", got) } } func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) { filter := newClaudeTUIOutputFilter("") - filter.Filter("\u25cf IOP_FIRST_MESSAGE\r\n\u2500\r\n") - filter.Filter("\u25cf IOP_SECOND_MESSAGE\r\n\u276f ") + filter.Filter("\u25cf IOP_FIRST_MESSAGE.\r\n\u2500\r\n") + got := filter.Filter("\u25cf IOP_SECOND_MESSAGE.\r\n\u276f ") - if got := filter.Flush(); got != "IOP_SECOND_MESSAGE\n" { - t.Fatalf("Flush() = %q, want %q", got, "IOP_SECOND_MESSAGE\n") + if got != "\nIOP_SECOND_MESSAGE." { + t.Fatalf("Filter() = %q, want latest message replacement", got) } } @@ -58,38 +57,106 @@ func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) { } } -func TestClaudeTUIOutputFilterFallsBackWhenAssistantMarkerIsAbsent(t *testing.T) { +func TestClaudeTUIOutputFilterSuppressesStartupNoiseBeforeAssistantMarker(t *testing.T) { filter := newClaudeTUIOutputFilter("hello") filter.Filter("\r\x1b[2Khello\r\n") - filter.Filter("\r\x1b[2KIOP_MARKERLESS_REPLY\r\n") - filter.Filter("\u276f ") + for _, chunk := range []string{ + "\r\x1b[2Ksud\uc5d0 \ub300\ud574\r\n", + "\u2722 Dilly-dallying\u2026\r\n", + "* Dilly-dallying\u2026\r\n", + "\r\x1b[2K\uc124\uba85\ud574\ubd10\r\n", + } { + if got := filter.Filter(chunk); got != "" { + t.Fatalf("Filter(%q) = %q, want suppressed startup noise", chunk, got) + } + } - if got := filter.Flush(); got != "IOP_MARKERLESS_REPLY\n" { - t.Fatalf("Flush() = %q, want %q", got, "IOP_MARKERLESS_REPLY\n") + if got := filter.Flush(); got != "" { + t.Fatalf("Flush() = %q, want no startup noise", got) } } func TestClaudeTUIOutputFilterKeepsMultilineAssistantMessage(t *testing.T) { filter := newClaudeTUIOutputFilter("") - filter.Filter("\u25cf line one\r\nline two\r\n\r\nline four\r\n\r\n\u273b Crunched for 4s\r\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n\u276f ") + got := filter.Filter("\u25cf line one\r\nline two\r\n\r\nline four.\r\n\r\n\u273b Crunched for 4s\r\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n\u276f ") - want := "line one\nline two\n\nline four\n" - if got := filter.Flush(); got != want { - t.Fatalf("Flush() = %q, want %q", got, want) + want := "line one\nline two\n\nline four." + if got != want { + t.Fatalf("Filter() = %q, want %q", got, want) } } -func TestClaudeTUIOutputFilterDropsFooterWithoutAssistantMarker(t *testing.T) { +func TestClaudeTUIOutputFilterDoesNotFallbackBeforeAssistantMarker(t *testing.T) { filter := newClaudeTUIOutputFilter("hello") filter.Filter("hello\r\n") - filter.Filter("real reply\r\n") + if got := filter.Filter("real reply.\r\n"); got != "" { + t.Fatalf("Filter() = %q, want markerless output suppressed", got) + } filter.Filter("\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n") - if got := filter.Flush(); got != "real reply\n" { - t.Fatalf("Flush() = %q, want %q", got, "real reply\n") + if got := filter.Flush(); got != "" { + t.Fatalf("Flush() = %q, want markerless output suppressed", got) + } +} + +func TestClaudeTUIOutputFilterKeepsAssistantAfterStartupNoise(t *testing.T) { + filter := newClaudeTUIOutputFilter("sudo\uc5d0 \ub300\ud574 \uc124\uba85\ud574\ubd10") + + for _, chunk := range []string{ + "\r\x1b[2Ksud\uc5d0 \ub300\ud574\r\n", + "\u2722 Dilly-dallying\u2026\r\n", + "* Dilly-dallying\u2026\r\n", + "\r\x1b[2K\uc124\uba85\ud574\ubd10\r\n", + } { + if got := filter.Filter(chunk); got != "" { + t.Fatalf("startup Filter(%q) = %q, want suppressed", chunk, got) + } + } + + got := filter.Filter("\u25cf sudo is a command for elevated privileges\r\n\u2722 Dilly-dallying\u2026\r\n\u276f ") + if got != "sudo is a command for elevated privileges" { + t.Fatalf("Filter() = %q, want assistant message only", got) + } +} + +func TestClaudeTUIOutputFilterWaitsForLineBoundary(t *testing.T) { + filter := newClaudeTUIOutputFilter("") + + if got := filter.Filter("\u25cf sudo runs commands"); got != "" { + t.Fatalf("first Filter() = %q", got) + } + got := filter.Filter("\r\n\u276f ") + if got != "sudo runs commands" { + t.Fatalf("second Filter() = %q, want completed line", got) + } +} + +func TestClaudeTUIOutputFilterEmitsSuffixAfterLineRepaint(t *testing.T) { + filter := newClaudeTUIOutputFilter("") + + if got := filter.Filter("\u25cf First sentence.\r\n\u276f "); got != "First sentence." { + t.Fatalf("first Filter() = %q", got) + } + got := filter.Filter("\r\x1b[2A\x1b[2K\u25cf First sentence. Second sentence.\r\n\u276f ") + if got != " Second sentence." { + t.Fatalf("second Filter() = %q, want suffix only", got) + } +} + +func TestClaudeTUIOutputFilterEmitsCompleteLineBeforePartialNextLine(t *testing.T) { + filter := newClaudeTUIOutputFilter("") + + got := filter.Filter("\u25cf line one\r\nline two") + if got != "line one\n" { + t.Fatalf("Filter() = %q, want first completed line", got) + } + + got = filter.Filter("\r\n\u276f ") + if got != "line two" { + t.Fatalf("second Filter() = %q, want completed second line", got) } } @@ -109,10 +176,10 @@ func TestClaudeTUIOutputFilterKeepsAssistantAfterTransientStatus(t *testing.T) { filter.Filter("\uc548\ub155?\r\n") filter.Filter("3\r\n\r\nThundering\u20266\r\n") - filter.Filter("\u25cf \uc548\ub155\ud558\uc138\uc694.\r\n") + got := filter.Filter("\u25cf \uc548\ub155\ud558\uc138\uc694.\r\n") - if got := filter.Flush(); got != "\uc548\ub155\ud558\uc138\uc694.\n" { - t.Fatalf("Flush() = %q, want %q", got, "\uc548\ub155\ud558\uc138\uc694.\n") + if got != "\uc548\ub155\ud558\uc138\uc694." { + t.Fatalf("Filter() = %q, want %q", got, "\uc548\ub155\ud558\uc138\uc694.") } } diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh index 41ba19d..06ed3d8 100755 --- a/scripts/e2e-smoke.sh +++ b/scripts/e2e-smoke.sh @@ -4,6 +4,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +echo "[e2e] NOTE: auxiliary smoke only; completion requires bin/edge.sh + bin/node.sh user-flow verification." + if command -v shellcheck >/dev/null 2>&1; then echo "[e2e] running shellcheck..." shellcheck "$0" "$REPO_ROOT/bin/edge.sh" "$REPO_ROOT/bin/node.sh" @@ -31,10 +33,13 @@ IS_PERSISTENT=0 HAS_STATUS=0 FIRST_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_alpha" FIRST_EXPECTED="SMOKE_ALPHA" +FIRST_TAIL_EXPECTED="IOP_SMOKE_ALPHA_TAIL" SECOND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_beta" SECOND_EXPECTED="SMOKE_BETA" +SECOND_TAIL_EXPECTED="IOP_SMOKE_BETA_TAIL" BACKGROUND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_bg" BACKGROUND_EXPECTED="SMOKE_BG" +BACKGROUND_TAIL_EXPECTED="IOP_SMOKE_BG_TAIL" if [ "$PROFILE" = "mock" ]; then echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..." @@ -45,9 +50,9 @@ if [ "$PROFILE" = "mock" ]; then #!/usr/bin/env sh while IFS= read -r line; do case "$line" in - *iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\n' ;; - *iop_smoke_beta*) printf 'IOP_SMOKE_BETA\n' ;; - *iop_smoke_bg*) printf 'IOP_SMOKE_BG\n' ;; + *iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\nIOP_SMOKE_ALPHA_TAIL\n' ;; + *iop_smoke_beta*) printf 'IOP_SMOKE_BETA\nIOP_SMOKE_BETA_TAIL\n' ;; + *iop_smoke_bg*) printf 'IOP_SMOKE_BG\nIOP_SMOKE_BG_TAIL\n' ;; *) printf 'IOP_SMOKE_UNKNOWN\n' ;; esac done @@ -197,7 +202,7 @@ wait_for_node_registration() { deadline=$((SECONDS + REGISTER_TIMEOUT)) echo "[e2e] waiting for node registration (timeout: ${REGISTER_TIMEOUT}s)" - while ! grep -q '\[node-test-node-event\] connected reason="registered"' "$EDGE_OUT"; do + while ! grep -q '\[node0-evt\] connected reason="registered"' "$EDGE_OUT"; do ensure_smoke_processes_running "node registration" if (( SECONDS >= deadline )); then echo "[e2e] node registration timed out" @@ -280,31 +285,31 @@ wait_for_node_registration send_cmd "/nodes" wait_for_edge_text_since "test-node (test-node)" "$LAST_CMD_START_LINE" "/nodes output" send_cmd "/capabilities" -wait_for_edge_text_since "[node-test-node-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output" +wait_for_edge_text_since "[node0-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output" wait_for_edge_text_since "targets = ${TARGET}" "$LAST_CMD_START_LINE" "/capabilities targets" send_cmd "/transport" -wait_for_edge_text_since "[node-test-node-transport]" "$LAST_CMD_START_LINE" "/transport output" +wait_for_edge_text_since "[node0-transport]" "$LAST_CMD_START_LINE" "/transport output" wait_for_edge_text_since "connected = true" "$LAST_CMD_START_LINE" "/transport connected status" send_cmd "$FIRST_PROMPT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT" wait_for_edge_text_since "$FIRST_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 1 response" "$RUN_TIMEOUT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT" send_cmd "$SECOND_PROMPT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT" wait_for_edge_text_since "$SECOND_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 2 response" "$RUN_TIMEOUT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT" send_cmd "/session session2" wait_for_edge_pattern_since "session .*session2" "$LAST_CMD_START_LINE" "/session acknowledgement" send_cmd "/background on" wait_for_edge_pattern_since "background .*on" "$LAST_CMD_START_LINE" "/background on acknowledgement" send_cmd "$BACKGROUND_PROMPT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT" wait_for_edge_text_since "$BACKGROUND_EXPECTED" "$LAST_CMD_START_LINE" "background run response" "$RUN_TIMEOUT" -wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT" +wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT" send_cmd "/background off" wait_for_edge_pattern_since "background .*off" "$LAST_CMD_START_LINE" "/background off acknowledgement" send_cmd "/sessions" -wait_for_edge_text_since "[node-test-node-sessions]" "$LAST_CMD_START_LINE" "/sessions output" +wait_for_edge_text_since "[node0-sessions]" "$LAST_CMD_START_LINE" "/sessions output" if [ "$IS_PERSISTENT" -eq 1 ]; then send_cmd "/terminate-session" @@ -313,7 +318,7 @@ fi if [ "$HAS_STATUS" -eq 1 ]; then send_cmd "/status" - wait_for_edge_text_since "[node-test-node-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT" + wait_for_edge_text_since "[node0-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT" fi IDLE_FAIL=0 @@ -378,30 +383,95 @@ check_fail_markers() { local file="$1" # Strict failure detection for system/console errors # We catch panics, unexpected exits, startup failures, and run-level error events. - if grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[node-.*-event\] error" "$file" >/dev/null; then + if grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[[^]]+-evt\] error" "$file" >/dev/null; then echo "[e2e] FAIL: detected error marker in $file" - grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[node-.*-event\] error" "$file" + grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[[^]]+-evt\] error" "$file" FAIL=1 fi } +check_node_messages_relayed_to_edge() { + local node_messages="$TMP_DIR/node_messages" + local edge_messages="$TMP_DIR/edge_messages" + local node_counts="$TMP_DIR/node_message_counts" + local count + local payload + local edge_count + + # Compare the messages the node printed locally with the messages rendered + # by the edge console. A complete event is not enough if some node output + # never reached the edge. + awk ' + /^\[node-message\] / { + in_message = 1 + sub(/^\[node-message\] /, "") + if ($0 != "") { + print + } + next + } + in_message && /^\[/ { + in_message = 0 + } + in_message && /^\{/ { + in_message = 0 + } + in_message && $0 != "" { + print + } + ' "$NODE_OUT" > "$node_messages" + + awk ' + /^\[[^]]+-msg\] / { + sub(/^\[[^]]+-msg\] /, "") + if ($0 != "") { + print + } + } + ' "$EDGE_OUT" > "$edge_messages" + + if [ ! -s "$node_messages" ]; then + echo "[e2e] FAIL: no node-generated message payloads found in node output" + FAIL=1 + return + fi + + sort "$node_messages" | uniq -c > "$node_counts" + while read -r count payload; do + if [ -z "$payload" ]; then + continue + fi + edge_count=$(grep -Fxc -- "$payload" "$edge_messages" || true) + if [ "$edge_count" -lt "$count" ]; then + echo "[e2e] FAIL: node message payload was not fully relayed to edge" + echo "[e2e] missing-or-short payload: $payload" + echo "[e2e] node_count=$count edge_count=$edge_count" + FAIL=1 + fi + done < "$node_counts" +} + check_grep "test-node" "$EDGE_OUT" "node registration not found" check_grep "start run_id=" "$EDGE_OUT" "run start not found" check_grep "complete run_id=" "$EDGE_OUT" "run completion not found" -if [ "$(grep -c "\\[node-test-node-event\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then +if [ "$(grep -c "\\[node0-evt\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then echo "[e2e] FAIL: expected at least 3 completed runs (foreground x2 + background x1)" FAIL=1 fi check_grep "$FIRST_EXPECTED" "$EDGE_OUT" "foreground run 1 response not found" +check_grep "$FIRST_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 1 tail response not found" check_grep "$SECOND_EXPECTED" "$EDGE_OUT" "foreground run 2 response not found" +check_grep "$SECOND_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 2 tail response not found" check_grep "$BACKGROUND_EXPECTED" "$EDGE_OUT" "background run response not found" -check_no_grep "\\[node-test-node-message\\] " "$EDGE_OUT" "empty node message found" +check_grep "$BACKGROUND_TAIL_EXPECTED" "$EDGE_OUT" "background run tail response not found" +check_no_grep "\\[node0-msg\\] " "$EDGE_OUT" "empty node message found" +check_node_messages_relayed_to_edge -check_grep "\\[node-test-node-capabilities\\]" "$EDGE_OUT" "/capabilities output not found" +check_grep "\\[node0-capabilities\\]" "$EDGE_OUT" "/capabilities output not found" check_grep "targets = ${TARGET}" "$EDGE_OUT" "/capabilities targets not found" -check_grep "\\[node-test-node-transport\\]" "$EDGE_OUT" "/transport output not found" +check_grep "\\[node0-transport\\]" "$EDGE_OUT" "/transport output not found" check_grep "connected = true" "$EDGE_OUT" "/transport connected status not found" -check_grep "\\[node-test-node-sessions\\]" "$EDGE_OUT" "/sessions output not found" +check_grep "\\[node0-sessions\\]" "$EDGE_OUT" "/sessions output not found" if [ "$IS_PERSISTENT" -eq 1 ]; then check_grep "persistent:${TARGET}/session2" "$EDGE_OUT" "/sessions entry for session2 not found" @@ -448,9 +518,10 @@ if [ "$PROFILE" != "mock" ]; then fi if [ $FAIL -eq 1 ]; then - echo "[e2e] Smoke test FAILED." + echo "[e2e] Auxiliary smoke test FAILED." exit 1 fi -echo "[e2e] Smoke test PASSED." +echo "[e2e] Auxiliary smoke test PASSED." +echo "[e2e] Completion still requires bin/edge.sh + bin/node.sh user-flow verification." exit 0